mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
Merge main and fix settings schema CI
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -234,6 +234,8 @@ yarn-error.log*
|
||||
|
||||
logs
|
||||
|
||||
ralph/
|
||||
|
||||
# agent
|
||||
.envrc
|
||||
/workspace
|
||||
|
||||
3676
enterprise/poetry.lock
generated
3676
enterprise/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,9 @@ PERMITTED_CORS_ORIGINS = [
|
||||
)
|
||||
]
|
||||
|
||||
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
|
||||
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""Build the LiteLLM proxy model path based on model name.
|
||||
|
||||
171
enterprise/server/sharing/aws_shared_event_service.py
Normal file
171
enterprise/server/sharing/aws_shared_event_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Implementation of SharedEventService for AWS S3.
|
||||
|
||||
This implementation provides read-only access to events from shared conversations:
|
||||
- Validates that the conversation is shared before returning events
|
||||
- Uses existing EventService for actual event retrieval
|
||||
- Uses SharedConversationInfoService for shared conversation validation
|
||||
|
||||
Uses role-based authentication (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
import boto3
|
||||
from fastapi import Request
|
||||
from pydantic import Field
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.sql_shared_conversation_info_service import (
|
||||
SQLSharedConversationInfoService,
|
||||
)
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event.aws_event_service import AwsEventService
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.sdk import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AwsSharedEventService(SharedEventService):
|
||||
"""Implementation of SharedEventService for AWS S3 that validates shared access.
|
||||
|
||||
Uses role-based authentication (no credentials needed).
|
||||
"""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
s3_client: Any
|
||||
bucket_name: str
|
||||
|
||||
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
return AwsEventService(
|
||||
s3_client=self.s3_client,
|
||||
bucket_name=self.bucket_name,
|
||||
prefix=Path('users'),
|
||||
user_id=shared_conversation_info.created_by_user_id,
|
||||
app_conversation_info_service=None,
|
||||
app_conversation_info_load_tasks={},
|
||||
)
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await event_service.get_event(conversation_id, event_id)
|
||||
|
||||
async def search_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> EventPage:
|
||||
"""Search events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return EventPage(items=[], next_page_id=None)
|
||||
|
||||
# If conversation is shared, search events for this conversation
|
||||
return await event_service.search_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def count_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await event_service.count_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
)
|
||||
|
||||
|
||||
class AwsSharedEventServiceInjector(SharedEventServiceInjector):
|
||||
bucket_name: str | None = Field(
|
||||
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedEventService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import get_db_session
|
||||
|
||||
async with get_db_session(state, request) as db_session:
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
bucket_name = self.bucket_name
|
||||
if bucket_name is None:
|
||||
raise ValueError(
|
||||
'bucket_name is required. Set FILE_STORE_PATH environment variable.'
|
||||
)
|
||||
|
||||
# Use role-based authentication - boto3 will automatically
|
||||
# use IAM role credentials when running in AWS
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=os.getenv('AWS_S3_ENDPOINT'),
|
||||
)
|
||||
|
||||
service = AwsSharedEventService(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
s3_client=s3_client,
|
||||
bucket_name=bucket_name,
|
||||
)
|
||||
yield service
|
||||
@@ -5,19 +5,45 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_event_service import SharedEventService
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.sdk import Event
|
||||
from openhands.utils.environment import StorageProvider, get_storage_provider
|
||||
|
||||
|
||||
def get_shared_event_service_injector() -> SharedEventServiceInjector:
|
||||
"""Get the appropriate SharedEventServiceInjector based on configuration.
|
||||
|
||||
Uses get_storage_provider() to determine the storage backend.
|
||||
See openhands.utils.environment for supported environment variables.
|
||||
|
||||
Note: Shared events only support AWS and GCP storage. Filesystem storage
|
||||
falls back to GCP for shared events.
|
||||
"""
|
||||
provider = get_storage_provider()
|
||||
|
||||
if provider == StorageProvider.AWS:
|
||||
from server.sharing.aws_shared_event_service import (
|
||||
AwsSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return AwsSharedEventServiceInjector()
|
||||
else:
|
||||
# GCP is the default for shared events (including filesystem fallback)
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return GoogleCloudSharedEventServiceInjector()
|
||||
|
||||
|
||||
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
|
||||
shared_event_service_dependency = Depends(
|
||||
GoogleCloudSharedEventServiceInjector().depends
|
||||
)
|
||||
shared_event_service_dependency = Depends(get_shared_event_service_injector().depends)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
@@ -119,6 +119,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
@@ -141,6 +142,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sandbox_id__eq=sandbox_id__eq,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
@@ -198,6 +200,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
) -> int:
|
||||
"""Count conversations matching the given filters with SAAS metadata."""
|
||||
query = (
|
||||
@@ -220,6 +223,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sandbox_id__eq=sandbox_id__eq,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
@@ -234,6 +238,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
):
|
||||
"""Apply filters to query that includes SAAS metadata."""
|
||||
# Apply the same filters as the base class
|
||||
@@ -259,6 +264,9 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
if sandbox_id__eq is not None:
|
||||
conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq)
|
||||
|
||||
if conditions:
|
||||
query = query.where(*conditions)
|
||||
return query
|
||||
|
||||
@@ -29,6 +29,15 @@ KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
try:
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if DEFAULT_INITIAL_BUDGET < 0:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
"""Generate the key alias for OpenHands Cloud managed keys."""
|
||||
@@ -101,7 +110,7 @@ class LiteLlmManager:
|
||||
) as client:
|
||||
# Check if team already exists and get its budget
|
||||
# New users joining existing orgs should inherit the team's budget
|
||||
team_budget = 0.0
|
||||
team_budget: float = DEFAULT_INITIAL_BUDGET
|
||||
try:
|
||||
existing_team = await LiteLlmManager._get_team(client, org_id)
|
||||
if existing_team:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
@@ -36,6 +37,8 @@ class OrgStore:
|
||||
org = Org(**kwargs)
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
|
||||
@@ -25,10 +25,10 @@ class SlackConversationStore:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_slack_conversation(
|
||||
self, slack_converstion: SlackConversation
|
||||
self, slack_conversation: SlackConversation
|
||||
) -> None:
|
||||
async with a_session_maker() as session:
|
||||
session.merge(slack_converstion)
|
||||
await session.merge(slack_conversation)
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
PERSONAL_WORKSPACE_VERSION_TO_MODEL,
|
||||
@@ -241,6 +242,10 @@ class UserStore:
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
# Apply DEFAULT_V1_ENABLED for migrated orgs if v1_enabled was not set
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
user_kwargs = UserStore.get_kwargs_from_user_settings(
|
||||
decrypted_user_settings
|
||||
)
|
||||
@@ -892,6 +897,8 @@ class UserStore:
|
||||
language='en', enable_proactive_conversation_starters=True
|
||||
)
|
||||
|
||||
default_settings.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
settings = await LiteLlmManager.create_entries(
|
||||
|
||||
@@ -28,6 +28,7 @@ from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.slack_conversation import SlackConversation # noqa: F401
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
|
||||
@@ -791,3 +791,202 @@ class TestSaasSQLAppConversationInfoServiceWebhookFallback:
|
||||
assert len(user1_page.items) == 1
|
||||
assert user1_page.items[0].id == conv_id
|
||||
assert user1_page.items[0].title == 'E2E Webhook Conversation'
|
||||
|
||||
|
||||
class TestSandboxIdFilterSaas:
|
||||
"""Test suite for sandbox_id__eq filter parameter in SAAS service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_sandbox_id(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test searching conversations by exact sandbox_id match with SAAS user filtering."""
|
||||
# Create service for user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations with different sandbox IDs for user1
|
||||
conv1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_alpha',
|
||||
title='Conversation Alpha',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_beta',
|
||||
title='Conversation Beta',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv3 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_alpha',
|
||||
title='Conversation Gamma',
|
||||
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save all conversations
|
||||
await user1_service.save_app_conversation_info(conv1)
|
||||
await user1_service.save_app_conversation_info(conv2)
|
||||
await user1_service.save_app_conversation_info(conv3)
|
||||
|
||||
# Search for sandbox_alpha - should return 2 conversations
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_alpha'
|
||||
)
|
||||
assert len(page.items) == 2
|
||||
sandbox_ids = {item.sandbox_id for item in page.items}
|
||||
assert sandbox_ids == {'sandbox_alpha'}
|
||||
conversation_ids = {item.id for item in page.items}
|
||||
assert conv1.id in conversation_ids
|
||||
assert conv3.id in conversation_ids
|
||||
|
||||
# Search for sandbox_beta - should return 1 conversation
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_beta'
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv2.id
|
||||
|
||||
# Search for non-existent sandbox - should return 0 conversations
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_nonexistent'
|
||||
)
|
||||
assert len(page.items) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_by_sandbox_id(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test counting conversations by exact sandbox_id match with SAAS user filtering."""
|
||||
# Create service for user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations with different sandbox IDs
|
||||
conv1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_x',
|
||||
title='Conversation X1',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_y',
|
||||
title='Conversation Y1',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv3 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_x',
|
||||
title='Conversation X2',
|
||||
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save all conversations
|
||||
await user1_service.save_app_conversation_info(conv1)
|
||||
await user1_service.save_app_conversation_info(conv2)
|
||||
await user1_service.save_app_conversation_info(conv3)
|
||||
|
||||
# Count for sandbox_x - should be 2
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_x'
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
# Count for sandbox_y - should be 1
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_y'
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Count for non-existent sandbox - should be 0
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_nonexistent'
|
||||
)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_id_filter_respects_user_isolation(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that sandbox_id filter respects user isolation in SAAS environment."""
|
||||
# Create services for both users
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
user2_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
|
||||
)
|
||||
|
||||
# Create conversation with same sandbox_id for both users
|
||||
shared_sandbox_id = 'sandbox_shared'
|
||||
|
||||
conv_user1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id=shared_sandbox_id,
|
||||
title='User1 Conversation',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv_user2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER2_ID),
|
||||
sandbox_id=shared_sandbox_id,
|
||||
title='User2 Conversation',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save conversations
|
||||
await user1_service.save_app_conversation_info(conv_user1)
|
||||
await user2_service.save_app_conversation_info(conv_user2)
|
||||
|
||||
# User1 should only see their own conversation with this sandbox_id
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv_user1.id
|
||||
assert page.items[0].title == 'User1 Conversation'
|
||||
|
||||
# User2 should only see their own conversation with this sandbox_id
|
||||
page = await user2_service.search_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv_user2.id
|
||||
assert page.items[0].title == 'User2 Conversation'
|
||||
|
||||
# Count should also respect user isolation
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
count = await user2_service.count_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Unit tests for SlackConversationStore."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_conversation_store():
|
||||
"""Create SlackConversationStore instance."""
|
||||
return SlackConversationStore()
|
||||
|
||||
|
||||
class TestSlackConversationStore:
|
||||
"""Test cases for SlackConversationStore."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_slack_conversation_persists_to_database(
|
||||
self, slack_conversation_store, async_session_maker
|
||||
):
|
||||
"""Test that create_slack_conversation actually stores data in the database.
|
||||
|
||||
This test verifies that the await statement is present before session.merge().
|
||||
Without the await, the data won't be persisted and subsequent lookups will
|
||||
return None even though we just created the conversation.
|
||||
"""
|
||||
channel_id = 'C123456'
|
||||
parent_id = '1234567890.123456'
|
||||
conversation_id = 'conv-test-123'
|
||||
keycloak_user_id = 'user-123'
|
||||
|
||||
slack_conversation = SlackConversation(
|
||||
conversation_id=conversation_id,
|
||||
channel_id=channel_id,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
with patch(
|
||||
'storage.slack_conversation_store.a_session_maker', async_session_maker
|
||||
):
|
||||
# Create the slack conversation
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
# Verify we can retrieve the conversation using the store method
|
||||
result = await slack_conversation_store.get_slack_conversation(
|
||||
channel_id=channel_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
# This assertion would fail if the await was missing before session.merge()
|
||||
# because the data wouldn't be persisted to the database
|
||||
assert result is not None, (
|
||||
'Slack conversation was not persisted to the database. '
|
||||
'Ensure await is used before session.merge() in create_slack_conversation.'
|
||||
)
|
||||
assert result.conversation_id == conversation_id
|
||||
assert result.channel_id == channel_id
|
||||
assert result.parent_id == parent_id
|
||||
assert result.keycloak_user_id == keycloak_user_id
|
||||
|
||||
# Also verify directly in the database
|
||||
async with async_session_maker() as session:
|
||||
db_result = await session.execute(
|
||||
select(SlackConversation).where(
|
||||
SlackConversation.channel_id == channel_id,
|
||||
SlackConversation.parent_id == parent_id,
|
||||
)
|
||||
)
|
||||
db_conversation = db_result.scalar_one_or_none()
|
||||
assert db_conversation is not None
|
||||
assert db_conversation.conversation_id == conversation_id
|
||||
@@ -2,7 +2,9 @@
|
||||
Unit tests for LiteLlmManager class.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -21,6 +23,71 @@ from storage.user_settings import UserSettings
|
||||
from openhands.server.settings import Settings
|
||||
|
||||
|
||||
class TestDefaultInitialBudget:
|
||||
"""Test cases for DEFAULT_INITIAL_BUDGET configuration."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_module_state(self):
|
||||
"""Ensure module is properly restored after each test."""
|
||||
# Save original module if it exists
|
||||
original_module = sys.modules.get('storage.lite_llm_manager')
|
||||
|
||||
yield
|
||||
|
||||
# Restore module state after each test
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
|
||||
# Restore original module or reimport fresh
|
||||
if original_module is not None:
|
||||
sys.modules['storage.lite_llm_manager'] = original_module
|
||||
else:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var and reimport
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 0.0
|
||||
|
||||
def test_default_initial_budget_uses_env_var(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 100.0
|
||||
|
||||
def test_default_initial_budget_rejects_invalid_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'Invalid DEFAULT_INITIAL_BUDGET' in str(exc_info.value)
|
||||
|
||||
def test_default_initial_budget_rejects_negative_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for negative values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'must be non-negative' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestLiteLlmManager:
|
||||
"""Test cases for LiteLlmManager class."""
|
||||
|
||||
@@ -242,10 +309,10 @@ class TestLiteLlmManager:
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 30.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_zero_budget(
|
||||
async def test_create_entries_new_org_uses_default_initial_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses budget=0 for new org (team doesn't exist)."""
|
||||
"""Test that create_entries uses DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
@@ -273,6 +340,7 @@ class TestLiteLlmManager:
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', 0.0),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
@@ -280,16 +348,67 @@ class TestLiteLlmManager:
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with budget=0
|
||||
# Verify _create_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == 0.0
|
||||
|
||||
# Verify _add_user_to_team was called with budget=0
|
||||
# Verify _add_user_to_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_custom_default_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses custom DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
custom_budget = 50.0
|
||||
with (
|
||||
patch.dict(os.environ, {'LOCAL_DEPLOYMENT': ''}),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', custom_budget),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == custom_budget
|
||||
|
||||
# Verify _add_user_to_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == custom_budget
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_propagates_non_404_errors(self, mock_settings):
|
||||
"""Test that create_entries propagates non-404 errors from _get_team."""
|
||||
|
||||
@@ -144,6 +144,86 @@ async def test_create_org(async_session_maker, mock_litellm_api):
|
||||
assert org.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_true_when_default_is_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to True
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-true'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_false_when_default_is_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to False
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-false'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_false_overrides_default_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True but org.v1_enabled is explicitly set to False
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay False (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-false', 'v1_enabled': False}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_true_overrides_default_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False but org.v1_enabled is explicitly set to True
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay True (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-true', 'v1_enabled': True}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_by_name(async_session_maker, mock_litellm_api):
|
||||
# Test getting org by name
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
"""Tests for AwsSharedEventService."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from server.sharing.aws_shared_event_service import (
|
||||
AwsSharedEventService,
|
||||
AwsSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_conversation_models import SharedConversation
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_shared_conversation_info_service():
|
||||
"""Create a mock SharedConversationInfoService."""
|
||||
return AsyncMock(spec=SharedConversationInfoService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_client():
|
||||
"""Create a mock S3 client."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_service():
|
||||
"""Create a mock EventService for returned by get_event_service."""
|
||||
return AsyncMock(spec=EventService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aws_shared_event_service(mock_shared_conversation_info_service, mock_s3_client):
|
||||
"""Create an AwsSharedEventService for testing."""
|
||||
return AwsSharedEventService(
|
||||
shared_conversation_info_service=mock_shared_conversation_info_service,
|
||||
s3_client=mock_s3_client,
|
||||
bucket_name='test-bucket',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_public_conversation():
|
||||
"""Create a sample public conversation."""
|
||||
return SharedConversation(
|
||||
id=uuid4(),
|
||||
created_by_user_id='test_user',
|
||||
sandbox_id='test_sandbox',
|
||||
title='Test Public Conversation',
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
metrics=MetricsSnapshot(
|
||||
accumulated_cost=0.0,
|
||||
max_budget_per_task=10.0,
|
||||
accumulated_token_usage=TokenUsage(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_event():
|
||||
"""Create a sample event."""
|
||||
# For testing purposes, we'll just use a mock that the EventPage can accept
|
||||
# The actual event creation is complex and not the focus of these tests
|
||||
return None
|
||||
|
||||
|
||||
class TestAwsSharedEventService:
|
||||
"""Test cases for AwsSharedEventService."""
|
||||
|
||||
async def test_get_shared_event_returns_event_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that get_shared_event returns an event for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return an event
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_shared_event(
|
||||
conversation_id, event_id
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == sample_event
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.get_event.assert_called_once_with(conversation_id, event_id)
|
||||
|
||||
async def test_get_shared_event_returns_none_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that get_shared_event returns None for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_shared_event(
|
||||
conversation_id, event_id
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called since get_event_service returns None
|
||||
mock_event_service.get_event.assert_not_called()
|
||||
|
||||
async def test_search_shared_events_returns_events_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that search_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_page = EventPage(items=[], next_page_id=None)
|
||||
mock_event_service.search_events.return_value = mock_event_page
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.search_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == mock_event_page
|
||||
assert len(result.items) == 0 # Empty list as we mocked
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.search_events.assert_called_once_with(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
sort_order=EventSortOrder.TIMESTAMP,
|
||||
page_id=None,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
async def test_search_shared_events_returns_empty_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that search_shared_events returns empty page for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.search_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, EventPage)
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
mock_event_service.search_events.assert_not_called()
|
||||
|
||||
async def test_count_shared_events_returns_count_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that count_shared_events returns count for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return a count
|
||||
mock_event_service.count_events.return_value = 5
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.count_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 5
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.count_events.assert_called_once_with(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
)
|
||||
|
||||
async def test_count_shared_events_returns_zero_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that count_shared_events returns 0 for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.count_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 0
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
mock_event_service.count_events.assert_not_called()
|
||||
|
||||
async def test_batch_get_shared_events_returns_events_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that batch_get_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_ids = [uuid4() for _ in range(3)]
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
# Call the method
|
||||
results = await aws_shared_event_service.batch_get_shared_events(
|
||||
conversation_id, event_ids
|
||||
)
|
||||
|
||||
# Verify the results
|
||||
assert len(results) == 3
|
||||
assert all(result == sample_event for result in results)
|
||||
|
||||
|
||||
class TestAwsSharedEventServiceGetEventService:
|
||||
"""Test cases for AwsSharedEventService.get_event_service method."""
|
||||
|
||||
async def test_get_event_service_returns_event_service_for_shared_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that get_event_service returns an EventService for a shared conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock the shared conversation info service to return a shared conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
async def test_get_event_service_returns_none_for_non_shared_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
):
|
||||
"""Test that get_event_service returns None for a non-shared conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock the shared conversation info service to return None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
|
||||
class TestAwsSharedEventServiceInjector:
|
||||
"""Test cases for AwsSharedEventServiceInjector."""
|
||||
|
||||
def test_bucket_name_from_environment_variable(self):
|
||||
"""Test that bucket_name is read from FILE_STORE_PATH environment variable."""
|
||||
test_bucket_name = 'test-bucket-name'
|
||||
with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}):
|
||||
# Create a new injector instance to pick up the environment variable
|
||||
# Note: The class attribute is evaluated at class definition time,
|
||||
# so we need to test that the attribute exists and can be overridden
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = os.environ.get('FILE_STORE_PATH')
|
||||
assert injector.bucket_name == test_bucket_name
|
||||
|
||||
def test_bucket_name_default_value_when_env_not_set(self):
|
||||
"""Test that bucket_name is None when FILE_STORE_PATH is not set."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Remove FILE_STORE_PATH if it exists
|
||||
os.environ.pop('FILE_STORE_PATH', None)
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
# The bucket_name will be whatever was set at class definition time
|
||||
# or None if FILE_STORE_PATH was not set when the class was defined
|
||||
assert hasattr(injector, 'bucket_name')
|
||||
|
||||
async def test_injector_yields_aws_shared_event_service(self):
|
||||
"""Test that the injector yields an AwsSharedEventService instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service is an instance of AwsSharedEventService
|
||||
assert isinstance(service, AwsSharedEventService)
|
||||
assert service.s3_client == mock_s3_client
|
||||
assert service.bucket_name == 'test-bucket'
|
||||
|
||||
async def test_injector_uses_bucket_name_from_instance(self):
|
||||
"""Test that the injector uses the bucket_name from the instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector with a specific bucket name
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'my-custom-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
assert service.bucket_name == 'my-custom-bucket'
|
||||
|
||||
async def test_injector_creates_sql_shared_conversation_info_service(self):
|
||||
"""Test that the injector creates SQLSharedConversationInfoService with db_session."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.SQLSharedConversationInfoService'
|
||||
) as mock_sql_service_class,
|
||||
):
|
||||
mock_sql_service = MagicMock()
|
||||
mock_sql_service_class.return_value = mock_sql_service
|
||||
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service has the correct shared_conversation_info_service
|
||||
assert service.shared_conversation_info_service == mock_sql_service
|
||||
|
||||
# Verify SQLSharedConversationInfoService was created with db_session
|
||||
mock_sql_service_class.assert_called_once_with(db_session=mock_db_session)
|
||||
|
||||
async def test_injector_works_without_request(self):
|
||||
"""Test that the injector works when request is None."""
|
||||
mock_state = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method with request=None
|
||||
async for service in injector.inject(mock_state, request=None):
|
||||
assert isinstance(service, AwsSharedEventService)
|
||||
|
||||
async def test_injector_uses_role_based_authentication(self):
|
||||
"""Test that the injector uses role-based authentication (no explicit credentials)."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
) as mock_boto3_client,
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch.dict(os.environ, {'AWS_S3_ENDPOINT': 'https://s3.example.com'}),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
pass
|
||||
|
||||
# Verify boto3.client was called with 's3' and endpoint_url
|
||||
# but without explicit credentials (role-based auth)
|
||||
mock_boto3_client.assert_called_once_with(
|
||||
's3',
|
||||
endpoint_url='https://s3.example.com',
|
||||
)
|
||||
171
enterprise/tests/unit/test_sharing/test_shared_event_router.py
Normal file
171
enterprise/tests/unit/test_sharing/test_shared_event_router.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for shared_event_router provider selection.
|
||||
|
||||
This module tests the get_shared_event_service_injector function which
|
||||
determines which SharedEventServiceInjector to use based on environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_event_router import get_shared_event_service_injector
|
||||
|
||||
|
||||
class TestGetSharedEventServiceInjector:
|
||||
"""Test cases for get_shared_event_service_injector function."""
|
||||
|
||||
def test_defaults_to_google_cloud_when_no_env_set(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when no env is set."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{},
|
||||
clear=True,
|
||||
):
|
||||
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
|
||||
os.environ.pop('FILE_STORE', None)
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_google_cloud_when_file_store_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_aws_when_provider_aws(self):
|
||||
"""Test that AwsSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=aws."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_uses_gcp_when_provider_gcp(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=gcp."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_gcp_when_provider_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_provider_takes_precedence_over_file_store(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER takes precedence over FILE_STORE."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use AWS because SHARED_EVENT_STORAGE_PROVIDER takes precedence
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_provider_gcp_takes_precedence_over_file_store_s3(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER=gcp takes precedence over FILE_STORE=s3."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
|
||||
'FILE_STORE': 's3',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use GCP because SHARED_EVENT_STORAGE_PROVIDER takes precedence
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_provider_is_case_insensitive_aws(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for AWS."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'AWS',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_provider_is_case_insensitive_gcp(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for GCP."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'GCP',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_unknown_provider_defaults_to_google_cloud(self):
|
||||
"""Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'unknown_provider',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_empty_provider_falls_back_to_file_store(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': '',
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
@@ -101,6 +101,72 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
|
||||
assert settings.llm_base_url == 'http://test.url'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_true_when_default_is_true(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to True
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', True),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_false_when_default_is_false(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to False
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', False),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is False
|
||||
|
||||
|
||||
# --- Tests for get_user_by_id ---
|
||||
|
||||
|
||||
@@ -1243,3 +1309,19 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
|
||||
assert (
|
||||
row.org_id == user_uuid_str
|
||||
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
|
||||
|
||||
|
||||
# Note: The v1_enabled logic in migrate_user follows the same pattern as OrgStore.create_org:
|
||||
# if org.v1_enabled is None:
|
||||
# org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
#
|
||||
# This behavior is tested in test_org_store.py via:
|
||||
# - test_create_org_v1_enabled_defaults_to_true_when_default_is_true
|
||||
# - test_create_org_v1_enabled_defaults_to_false_when_default_is_false
|
||||
# - test_create_org_v1_enabled_explicit_false_overrides_default_true
|
||||
# - test_create_org_v1_enabled_explicit_true_overrides_default_false
|
||||
#
|
||||
# Testing migrate_user directly is impractical due to its complex raw SQL migration
|
||||
# statements that have SQLite/UUID compatibility issues in the test environment.
|
||||
# The SQL migration tests above (test_migrate_user_sql_type_handling, etc.) verify
|
||||
# the SQL operations work correctly with proper type handling.
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -8,3 +8,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.react-router/
|
||||
ralph/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";
|
||||
import axios from "axios";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
@@ -6,6 +7,8 @@ vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
describe("V1ConversationService", () => {
|
||||
describe("readConversationFile", () => {
|
||||
it("uses default plan path when filePath is not provided", async () => {
|
||||
@@ -24,4 +27,91 @@ describe("V1ConversationService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.post as Mock).mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("uses query params for file upload path", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
const uploadPath = "/workspace/custom/path.txt";
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
uploadPath,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Verify URL uses query params format
|
||||
expect(callUrl).toContain("/api/file/upload?");
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt");
|
||||
|
||||
// Verify it's NOT using path params format
|
||||
expect(callUrl).not.toContain("/api/file/upload/%2F");
|
||||
});
|
||||
|
||||
it("uses default workspace path when no path provided", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "myfile.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Default path should be /workspace/{filename}
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt");
|
||||
});
|
||||
|
||||
it("sends file as FormData with correct headers", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (axios.post as Mock).mock.calls[0];
|
||||
|
||||
// Verify FormData is sent
|
||||
const formData = callArgs[1];
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.get("file")).toBe(file);
|
||||
|
||||
// Verify headers include session API key and content type
|
||||
const headers = callArgs[2].headers;
|
||||
expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey);
|
||||
expect(headers).toHaveProperty("Content-Type", "multipart/form-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("ExpandableMessage", () => {
|
||||
|
||||
it("should render the out of credits message when the user is out of credits", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - We only care about the app_mode and feature_flags fields
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { renderWithProviders } from "../../../test-utils";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMockWebClientConfig } from "../../helpers/mock-config";
|
||||
|
||||
const mockTrackAddTeamMembersButtonClick = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock posthog feature flag
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked module to get access to the mock
|
||||
import * as posthog from "posthog-js/react";
|
||||
|
||||
describe("AccountSettingsContextMenu", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
const onLogoutMock = vi.fn();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Set default feature flag to false
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Create a wrapper with MemoryRouter and renderWithProviders
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
const renderWithSaasConfig = (ui: React.ReactElement, options?: { analyticsConsent?: boolean }) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" }));
|
||||
queryClient.setQueryData(["settings"], { user_consents_to_analytics: options?.analyticsConsent ?? true });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithOssConfig = (ui: React.ReactElement) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
onCloseMock.mockClear();
|
||||
mockTrackAddTeamMembersButtonClick.mockClear();
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
|
||||
});
|
||||
|
||||
it("should always render the right options", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Documentation link with correct attributes", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
||||
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
||||
expect(documentationLink).toHaveAttribute("target", "_blank");
|
||||
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("logout button is always enabled", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithOssConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button when analytics consent is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
{ analyticsConsent: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call tracking function and onClose when Add Team Members button is clicked", async () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
|
||||
await user.click(addTeamMembersButton);
|
||||
|
||||
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
|
||||
bitbucket_data_center:
|
||||
"https://bitbucket-dc.example.com/site/oauth2/authorize",
|
||||
enterprise_sso: "https://auth.example.com/realms/test/protocol/openid-connect/auth",
|
||||
};
|
||||
if (config.appMode === "saas") {
|
||||
return urls[config.identityProvider] || null;
|
||||
@@ -117,6 +118,74 @@ describe("LoginContent", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Enterprise SSO button when configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Enterprise SSO alongside other providers when all configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["github", "gitlab", "bitbucket", "enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to Enterprise SSO auth URL when Enterprise SSO button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://auth.example.com/realms/test/protocol/openid-connect/auth";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const enterpriseSsoButton = screen.getByRole("button", {
|
||||
name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i,
|
||||
});
|
||||
await user.click(enterpriseSsoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toContain(mockUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display message when no providers are configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
Trans: ({
|
||||
values,
|
||||
components,
|
||||
}: {
|
||||
values: { email: string };
|
||||
components: { email: React.ReactElement };
|
||||
}) => React.cloneElement(components.email, {}, values.email),
|
||||
}));
|
||||
|
||||
describe("ConfirmRemoveMemberModal", () => {
|
||||
it("should display the member email in the confirmation message", () => {
|
||||
// Arrange
|
||||
const memberEmail = "test@example.com";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(memberEmail)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onConfirm when the confirm button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onConfirmMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Assert
|
||||
expect(onConfirmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onCancel when the cancel button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onCancelMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Assert
|
||||
expect(onCancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should disable buttons and show loading spinner when isLoading is true", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
Trans: ({
|
||||
values,
|
||||
components,
|
||||
}: {
|
||||
values: { email: string; role: string };
|
||||
components: { email: React.ReactElement; role: React.ReactElement };
|
||||
}) => (
|
||||
<>
|
||||
{React.cloneElement(components.email, {}, values.email)}
|
||||
{React.cloneElement(components.role, {}, values.role)}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ConfirmUpdateRoleModal", () => {
|
||||
it("should display the member email and new role in the confirmation message", () => {
|
||||
// Arrange
|
||||
const memberEmail = "test@example.com";
|
||||
const newRole = "admin";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
newRole={newRole}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(memberEmail)).toBeInTheDocument();
|
||||
expect(screen.getByText(newRole)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onConfirm when the confirm button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onConfirmMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Assert
|
||||
expect(onConfirmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onCancel when the cancel button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onCancelMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Assert
|
||||
expect(onCancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should disable buttons and show loading spinner when isLoading is true", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { within, screen, render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: vi.fn(() => ({ revalidate: vi.fn() })),
|
||||
}));
|
||||
|
||||
const renderInviteOrganizationMemberModal = (config?: {
|
||||
onClose: () => void;
|
||||
}) =>
|
||||
render(
|
||||
<InviteOrganizationMemberModal onClose={config?.onClose || vi.fn()} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
describe("InviteOrganizationMemberModal", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should call onClose the modal when the close button is clicked", async () => {
|
||||
const onCloseMock = vi.fn();
|
||||
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
|
||||
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
const closeButton = within(modal).getByRole("button", {
|
||||
name: /close/i,
|
||||
});
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call the batch API to invite a single team member when the form is submitted", async () => {
|
||||
const inviteMembersBatchSpy = vi.spyOn(
|
||||
organizationService,
|
||||
"inviteMembers",
|
||||
);
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
|
||||
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
|
||||
const badgeInput = within(modal).getByTestId("emails-badge-input");
|
||||
await userEvent.type(badgeInput, "someone@acme.org ");
|
||||
|
||||
// Verify badge is displayed
|
||||
expect(screen.getByText("someone@acme.org")).toBeInTheDocument();
|
||||
|
||||
const submitButton = within(modal).getByRole("button", {
|
||||
name: /add/i,
|
||||
});
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({
|
||||
orgId: "1",
|
||||
emails: ["someone@acme.org"],
|
||||
});
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should allow adding multiple emails using badge input and make a batch POST request", async () => {
|
||||
const inviteMembersBatchSpy = vi.spyOn(
|
||||
organizationService,
|
||||
"inviteMembers",
|
||||
);
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
|
||||
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
|
||||
// Should have badge input instead of regular input
|
||||
const badgeInput = within(modal).getByTestId("emails-badge-input");
|
||||
expect(badgeInput).toBeInTheDocument();
|
||||
|
||||
// Add first email by typing and pressing space
|
||||
await userEvent.type(badgeInput, "user1@acme.org ");
|
||||
|
||||
// Add second email by typing and pressing space
|
||||
await userEvent.type(badgeInput, "user2@acme.org ");
|
||||
|
||||
// Add third email by typing and pressing space
|
||||
await userEvent.type(badgeInput, "user3@acme.org ");
|
||||
|
||||
// Verify badges are displayed
|
||||
expect(screen.getByText("user1@acme.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("user2@acme.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("user3@acme.org")).toBeInTheDocument();
|
||||
|
||||
const submitButton = within(modal).getByRole("button", {
|
||||
name: /add/i,
|
||||
});
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Should call batch invite API with all emails
|
||||
expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({
|
||||
orgId: "1",
|
||||
emails: ["user1@acme.org", "user2@acme.org", "user3@acme.org"],
|
||||
});
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display an error toast when clicking add button with no emails added", async () => {
|
||||
// Arrange
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
const inviteMembersSpy = vi.spyOn(organizationService, "inviteMembers");
|
||||
renderInviteOrganizationMemberModal();
|
||||
|
||||
// Act
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
const submitButton = within(modal).getByRole("button", { name: /add/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Assert
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith(
|
||||
"ORG$NO_EMAILS_ADDED_HINT",
|
||||
);
|
||||
expect(inviteMembersSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
203
frontend/__tests__/components/features/org/org-selector.test.tsx
Normal file
203
frontend/__tests__/components/features/org/org-selector.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { screen, render, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { OrgSelector } from "#/components/features/org/org-selector";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import {
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
createMockOrganization,
|
||||
} from "#/mocks/org-handlers";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
useNavigate: () => vi.fn(),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useMatch: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature)
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({ data: { app_mode: "saas" } }),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization",
|
||||
"ORG$PERSONAL_WORKSPACE": "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderOrgSelector = () =>
|
||||
render(<OrgSelector />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("OrgSelector", () => {
|
||||
it("should not render when user only has a personal workspace", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
const { container } = renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render when user only has a team organization", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const { container } = renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a loading indicator when fetching organizations", () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockImplementation(
|
||||
() => new Promise(() => {}), // never resolves
|
||||
);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
// The dropdown trigger should be disabled while loading
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(trigger).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should select the first organization after orgs are loaded", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
// The combobox input should show the first org name
|
||||
await waitFor(() => {
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Personal Workspace");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show all options when dropdown is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
createMockOrganization("3", "Test Organization", 500),
|
||||
],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
// Wait for the selector to be populated with the first organization
|
||||
await waitFor(() => {
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Click the trigger to open dropdown
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
// Verify all 3 options are visible
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const options = within(listbox).getAllByRole("option");
|
||||
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0]).toHaveTextContent("Personal Workspace");
|
||||
expect(options[1]).toHaveTextContent("Acme Corp");
|
||||
expect(options[2]).toHaveTextContent("Test Organization");
|
||||
});
|
||||
|
||||
it("should call switchOrganization API when selecting a different organization", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
const switchOrgSpy = vi
|
||||
.spyOn(organizationService, "switchOrganization")
|
||||
.mockResolvedValue(MOCK_TEAM_ORG_ACME);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const acmeOption = within(listbox).getByText("Acme Corp");
|
||||
await user.click(acmeOption);
|
||||
|
||||
// Assert
|
||||
expect(switchOrgSpy).toHaveBeenCalledWith({ orgId: MOCK_TEAM_ORG_ACME.id });
|
||||
});
|
||||
|
||||
it("should show loading state while switching organizations", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "switchOrganization").mockImplementation(
|
||||
() => new Promise(() => {}), // never resolves to keep loading state
|
||||
);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const acmeOption = within(listbox).getByText("Acme Corp");
|
||||
await user.click(acmeOption);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { SettingsNavigation } from "#/components/features/settings/settings-navigation";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
...(await vi.importActual("react-router")),
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockConfig = () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org" && item.to !== "/settings/org-members",
|
||||
);
|
||||
|
||||
const renderSettingsNavigation = (
|
||||
items: SettingsNavItem[] = SAAS_NAV_ITEMS,
|
||||
) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<SettingsNavigation
|
||||
isMobileMenuOpen={false}
|
||||
onCloseMobileMenu={vi.fn()}
|
||||
navigationItems={items}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("SettingsNavigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConfig();
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
});
|
||||
|
||||
describe("renders navigation items passed via props", () => {
|
||||
it("should render org routes when included in navigation items", async () => {
|
||||
renderSettingsNavigation(SAAS_NAV_ITEMS);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
const orgMembersLink = await screen.findByText("Organization Members");
|
||||
const orgLink = await screen.findByText("Organization");
|
||||
|
||||
expect(orgMembersLink).toBeInTheDocument();
|
||||
expect(orgLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render org routes when excluded from navigation items", async () => {
|
||||
renderSettingsNavigation(ITEMS_WITHOUT_ORG);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
const orgMembersLink = screen.queryByText("Organization Members");
|
||||
const orgLink = screen.queryByText("Organization");
|
||||
|
||||
expect(orgMembersLink).not.toBeInTheDocument();
|
||||
expect(orgLink).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all non-org SAAS items regardless of which items are passed", async () => {
|
||||
renderSettingsNavigation(SAAS_NAV_ITEMS);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Verify non-org items are rendered (using their i18n keys as text since
|
||||
// react-i18next returns the key when no translation is loaded)
|
||||
const secretsLink = await screen.findByText("SETTINGS$NAV_SECRETS");
|
||||
const apiKeysLink = await screen.findByText("SETTINGS$NAV_API_KEYS");
|
||||
|
||||
expect(secretsLink).toBeInTheDocument();
|
||||
expect(apiKeysLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render empty nav when given an empty items list", async () => {
|
||||
renderSettingsNavigation([]);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// No nav links should be rendered
|
||||
const orgMembersLink = screen.queryByText("Organization Members");
|
||||
const orgLink = screen.queryByText("Organization");
|
||||
|
||||
expect(orgMembersLink).not.toBeInTheDocument();
|
||||
expect(orgLink).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,633 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { UserContextMenu } from "#/components/features/user/user-context-menu";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { GetComponentPropTypes } from "#/utils/get-component-prop-types";
|
||||
import {
|
||||
INITIAL_MOCK_ORGS,
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
} from "#/mocks/org-handlers";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
|
||||
|
||||
function UserContextMenuWithRootOutlet({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) {
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="portal-root" id="portal-root" />
|
||||
<UserContextMenu
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onOpenInviteModal={onOpenInviteModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderUserContextMenu = ({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) =>
|
||||
render(
|
||||
<UserContextMenuWithRootOutlet
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onOpenInviteModal={onOpenInviteModal}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: () => navigateMock,
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useIsAuthed to return authenticated state
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
||||
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("UserContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
// Ensure clean state at the start of each test
|
||||
vi.restoreAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
navigateMock.mockClear();
|
||||
// Reset Zustand store to ensure clean state between tests
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should render the default context items for a user", () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
|
||||
expect(
|
||||
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("COMMON$ORGANIZATION"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
|
||||
const expectedItems = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org-members" &&
|
||||
item.to !== "/settings/org" &&
|
||||
item.to !== "/settings/billing",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expectedItems.forEach((item) => {
|
||||
expect(screen.getByText(item.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should render navigation items from SAAS_NAV_ITEMS when user role is admin (except organization-members/org)", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
|
||||
const expectedItems = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org-members" && item.to !== "/settings/org",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expectedItems.forEach((item) => {
|
||||
expect(screen.getByText(item.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not display Organization Members menu item for regular users (filtered out)", () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Organization Members is filtered out from nav items for all users
|
||||
expect(screen.queryByText("Organization Members")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a documentation link", () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
const docsLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
||||
expect(docsLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
describe("OSS mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "oss",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render OSS_NAV_ITEMS when in OSS mode", async () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for the config to load and OSS nav items to appear
|
||||
await waitFor(() => {
|
||||
OSS_NAV_ITEMS.forEach((item) => {
|
||||
expect(screen.getByText(item.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Verify SAAS-only items are NOT rendered (e.g., Billing)
|
||||
expect(
|
||||
screen.queryByText("SETTINGS$NAV_BILLING"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display Organization Members menu item in OSS mode", async () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for the config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify Organization Members is NOT rendered in OSS mode
|
||||
expect(
|
||||
screen.queryByText("Organization Members"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HIDE_LLM_SETTINGS feature flag", () => {
|
||||
it("should hide LLM settings link when HIDE_LLM_SETTINGS is true", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: true,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
await waitFor(() => {
|
||||
// Other nav items should still be visible
|
||||
expect(screen.getByText("SETTINGS$NAV_USER")).toBeInTheDocument();
|
||||
// LLM settings (to: "/settings") should NOT be visible
|
||||
expect(
|
||||
screen.queryByText("COMMON$LANGUAGE_MODEL_LLM"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show LLM settings link when HIDE_LLM_SETTINGS is false", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("COMMON$LANGUAGE_MODEL_LLM"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should render additional context items when user is an admin", () => {
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
||||
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
||||
screen.getByText("COMMON$ORGANIZATION");
|
||||
});
|
||||
|
||||
it("should render additional context items when user is an owner", () => {
|
||||
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
||||
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
||||
screen.getByText("COMMON$ORGANIZATION");
|
||||
});
|
||||
|
||||
it("should call the logout handler when Logout is clicked", async () => {
|
||||
const logoutSpy = vi.spyOn(AuthService, "logout");
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should have correct navigation links for nav items", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true, // Enable billing so billing link is shown
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load and test a few representative nav items have the correct href
|
||||
await waitFor(() => {
|
||||
const userLink = screen.getByText("SETTINGS$NAV_USER").closest("a");
|
||||
expect(userLink).toHaveAttribute("href", "/settings/user");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a");
|
||||
expect(billingLink).toHaveAttribute("href", "/settings/billing");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const integrationsLink = screen
|
||||
.getByText("SETTINGS$NAV_INTEGRATIONS")
|
||||
.closest("a");
|
||||
expect(integrationsLink).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageOrganizationMembersButton = await screen.findByText(
|
||||
"ORG$ORGANIZATION_MEMBERS",
|
||||
);
|
||||
await userEvent.click(manageOrganizationMembersButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
|
||||
"/settings/org-members",
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate to /settings/org when Manage Account is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageAccountButton = await screen.findByText(
|
||||
"COMMON$ORGANIZATION",
|
||||
);
|
||||
await userEvent.click(manageAccountButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
|
||||
});
|
||||
|
||||
it("should call the onClose handler when clicking outside the context menu", async () => {
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
await userEvent.click(contextMenu);
|
||||
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate clicking outside the context menu
|
||||
await userEvent.click(document.body);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call the onClose handler after each action", async () => {
|
||||
// Mock a team org so org management buttons are visible
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await userEvent.click(logoutButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageOrganizationMembersButton = await screen.findByText(
|
||||
"ORG$ORGANIZATION_MEMBERS",
|
||||
);
|
||||
await userEvent.click(manageOrganizationMembersButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
|
||||
await userEvent.click(manageAccountButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
describe("Personal org vs team org visibility", () => {
|
||||
it("should not show Organization and Organization Members settings items when personal org is selected", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Pre-select the personal org in the Zustand store
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for org selector to load and org management buttons to disappear
|
||||
// (they disappear when personal org is selected)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("COMMON$ORGANIZATION"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Billing settings item when team org is selected", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for org selector to load and billing to disappear
|
||||
// (billing disappears when team org is selected)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("SETTINGS$NAV_BILLING"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
const onOpenInviteModalMock = vi.fn();
|
||||
renderUserContextMenu({
|
||||
type: "admin",
|
||||
onClose: onCloseMock,
|
||||
onOpenInviteModal: onOpenInviteModalMock,
|
||||
});
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
|
||||
await userEvent.click(inviteButton);
|
||||
|
||||
expect(onOpenInviteModalMock).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("the user can change orgs", async () => {
|
||||
// Mock SaaS mode and organizations for this test
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: INITIAL_MOCK_ORGS,
|
||||
currentOrgId: INITIAL_MOCK_ORGS[0].id,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for org selector to appear (it may take a moment for config to load)
|
||||
const orgSelector = await screen.findByTestId("org-selector");
|
||||
expect(orgSelector).toBeInTheDocument();
|
||||
|
||||
// Wait for organizations to load (indicated by org name appearing in the dropdown)
|
||||
// INITIAL_MOCK_ORGS[0] is a personal org, so it displays "Personal Workspace"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Open the dropdown by clicking the trigger
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
// Select a different organization
|
||||
const orgOption = screen.getByRole("option", {
|
||||
name: INITIAL_MOCK_ORGS[1].name,
|
||||
});
|
||||
await user.click(orgOption);
|
||||
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the dropdown shows the selected organization
|
||||
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import translations from "../../src/i18n/translation.json";
|
||||
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
|
||||
|
||||
vi.mock("@heroui/react", () => ({
|
||||
Tooltip: ({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const supportedLanguages = [
|
||||
"en",
|
||||
"ja",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"ko-KR",
|
||||
"de",
|
||||
"no",
|
||||
"it",
|
||||
"pt",
|
||||
"es",
|
||||
"ar",
|
||||
"fr",
|
||||
"tr",
|
||||
];
|
||||
|
||||
// Helper function to check if a translation exists for all supported languages
|
||||
function checkTranslationExists(key: string) {
|
||||
const missingTranslations: string[] = [];
|
||||
|
||||
const translationEntry = (
|
||||
translations as Record<string, Record<string, string>>
|
||||
)[key];
|
||||
if (!translationEntry) {
|
||||
throw new Error(
|
||||
`Translation key "${key}" does not exist in translation.json`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const lang of supportedLanguages) {
|
||||
if (!translationEntry[lang]) {
|
||||
missingTranslations.push(lang);
|
||||
}
|
||||
}
|
||||
|
||||
return missingTranslations;
|
||||
}
|
||||
|
||||
// Helper function to find duplicate translation keys
|
||||
function findDuplicateKeys(obj: Record<string, any>) {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
|
||||
// Only check top-level keys as these are our translation keys
|
||||
for (const key in obj) {
|
||||
if (seen.has(key)) {
|
||||
duplicates.add(key);
|
||||
} else {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicates);
|
||||
}
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translationEntry = (
|
||||
translations as Record<string, Record<string, string>>
|
||||
)[key];
|
||||
return translationEntry?.ja || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Landing page translations", () => {
|
||||
test("should render Japanese translations correctly", () => {
|
||||
// Mock a simple component that uses the translations
|
||||
const TestComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<UserAvatar onClick={() => {}} />
|
||||
<div data-testid="main-content">
|
||||
<h1>{t("LANDING$TITLE")}</h1>
|
||||
<button>{t("VSCODE$OPEN")}</button>
|
||||
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
|
||||
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
|
||||
<button>{t("SUGGESTIONS$FIX_README")}</button>
|
||||
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
|
||||
</div>
|
||||
<div data-testid="tabs">
|
||||
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
|
||||
</div>
|
||||
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
|
||||
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
|
||||
<div data-testid="status">
|
||||
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
|
||||
<span>{t("STATUS$CONNECTED")}</span>
|
||||
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
|
||||
</div>
|
||||
<div data-testid="time">
|
||||
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
|
||||
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
|
||||
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Check main content translations
|
||||
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
|
||||
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("テストカバレッジを向上させる"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
|
||||
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
|
||||
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
|
||||
|
||||
// Check tab labels
|
||||
const tabs = screen.getByTestId("tabs");
|
||||
expect(tabs).toHaveTextContent("ターミナル");
|
||||
expect(tabs).toHaveTextContent("ブラウザ");
|
||||
expect(tabs).toHaveTextContent("Jupyter");
|
||||
expect(tabs).toHaveTextContent("コードエディタ");
|
||||
|
||||
// Check workspace label and new project button
|
||||
expect(screen.getByTestId("workspace-label")).toHaveTextContent(
|
||||
"ワークスペース",
|
||||
);
|
||||
expect(screen.getByTestId("new-project")).toHaveTextContent(
|
||||
"新規プロジェクト",
|
||||
);
|
||||
|
||||
// Check status messages
|
||||
const status = screen.getByTestId("status");
|
||||
expect(status).toHaveTextContent("クライアントの準備を待機中");
|
||||
expect(status).toHaveTextContent("接続済み");
|
||||
expect(status).toHaveTextContent("サーバーに接続済み");
|
||||
|
||||
// Check time-related translations
|
||||
const time = screen.getByTestId("time");
|
||||
expect(time).toHaveTextContent("5 分前");
|
||||
expect(time).toHaveTextContent("2 時間前");
|
||||
expect(time).toHaveTextContent("3 日前");
|
||||
});
|
||||
|
||||
test("all translation keys should have translations for all supported languages", () => {
|
||||
// Test all translation keys used in the component
|
||||
const translationKeys = [
|
||||
"LANDING$TITLE",
|
||||
"VSCODE$OPEN",
|
||||
"SUGGESTIONS$INCREASE_TEST_COVERAGE",
|
||||
"SUGGESTIONS$AUTO_MERGE_PRS",
|
||||
"SUGGESTIONS$FIX_README",
|
||||
"SUGGESTIONS$CLEAN_DEPENDENCIES",
|
||||
"WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
"WORKSPACE$BROWSER_TAB_LABEL",
|
||||
"WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
"WORKSPACE$CODE_EDITOR_TAB_LABEL",
|
||||
"WORKSPACE$TITLE",
|
||||
"PROJECT$NEW_PROJECT",
|
||||
"TERMINAL$WAITING_FOR_CLIENT",
|
||||
"STATUS$CONNECTED",
|
||||
"STATUS$CONNECTED_TO_SERVER",
|
||||
"TIME$MINUTES_AGO",
|
||||
"TIME$HOURS_AGO",
|
||||
"TIME$DAYS_AGO",
|
||||
];
|
||||
|
||||
// Check all keys and collect missing translations
|
||||
const missingTranslationsMap = new Map<string, string[]>();
|
||||
translationKeys.forEach((key) => {
|
||||
const missing = checkTranslationExists(key);
|
||||
if (missing.length > 0) {
|
||||
missingTranslationsMap.set(key, missing);
|
||||
}
|
||||
});
|
||||
|
||||
// If any translations are missing, throw an error with all missing translations
|
||||
if (missingTranslationsMap.size > 0) {
|
||||
const errorMessage = Array.from(missingTranslationsMap.entries())
|
||||
.map(
|
||||
([key, langs]) =>
|
||||
`\n- "${key}" is missing translations for: ${langs.join(", ")}`,
|
||||
)
|
||||
.join("");
|
||||
throw new Error(`Missing translations:${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("translation file should not have duplicate keys", () => {
|
||||
const duplicates = findDuplicateKeys(translations);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(
|
||||
`Found duplicate translation keys: ${duplicates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
429
frontend/__tests__/components/ui/dropdown.test.tsx
Normal file
429
frontend/__tests__/components/ui/dropdown.test.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Dropdown } from "#/ui/dropdown/dropdown";
|
||||
|
||||
const mockOptions = [
|
||||
{ value: "1", label: "Option 1" },
|
||||
{ value: "2", label: "Option 2" },
|
||||
{ value: "3", label: "Option 3" },
|
||||
];
|
||||
|
||||
describe("Dropdown", () => {
|
||||
describe("Trigger", () => {
|
||||
it("should render a custom trigger button", () => {
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
|
||||
expect(trigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open dropdown on trigger click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Type-ahead / Search", () => {
|
||||
it("should filter options based on input text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Option 1");
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 3")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be case-insensitive by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "option 1");
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all options when search is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Option 1");
|
||||
await user.clear(input);
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty state", () => {
|
||||
it("should display empty state when no options provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={[]} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByText("No options")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render custom empty state message", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={[]} emptyMessage="Nothing found" />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByText("Nothing found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Single selection", () => {
|
||||
it("should select an option on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const option = screen.getByText("Option 1");
|
||||
await user.click(option);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Option 1");
|
||||
});
|
||||
|
||||
it("should close dropdown after selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display selected option in input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Option 1");
|
||||
});
|
||||
|
||||
it("should highlight currently selected option in list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const selectedOption = screen.getByRole("option", { name: "Option 1" });
|
||||
expect(selectedOption).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should preserve selected value in input and show all options when reopening dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
// Reopen the dropdown
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Option 1");
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 1" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 3" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clear button", () => {
|
||||
it("should not render clear button by default", () => {
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render clear button when clearable prop is true and has value", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should clear selection and search input when clear button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
const clearButton = screen.getByTestId("dropdown-clear");
|
||||
await user.click(clearButton);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should not render clear button when there is no selection", () => {
|
||||
render(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show placeholder after clearing selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Dropdown
|
||||
options={mockOptions}
|
||||
clearable
|
||||
placeholder="Select an option"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
const clearButton = screen.getByTestId("dropdown-clear");
|
||||
await user.click(clearButton);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading state", () => {
|
||||
it("should not display loading indicator by default", () => {
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display loading indicator when loading prop is true", () => {
|
||||
render(<Dropdown options={mockOptions} loading />);
|
||||
|
||||
expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable interaction while loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} loading />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled state", () => {
|
||||
it("should not open dropdown when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} disabled />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("should have disabled attribute on trigger", () => {
|
||||
render(<Dropdown options={mockOptions} disabled />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(trigger).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placeholder", () => {
|
||||
it("should display placeholder text when no value selected", () => {
|
||||
render(<Dropdown options={mockOptions} placeholder="Select an option" />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveAttribute("placeholder", "Select an option");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default value", () => {
|
||||
it("should display defaultValue in input on mount", () => {
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Option 1");
|
||||
});
|
||||
|
||||
it("should show all options when opened with defaultValue", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 1" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 3" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should restore input value when closed with Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "test");
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(input).toHaveValue("Option 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onChange", () => {
|
||||
it("should call onChange with selected item when option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
render(<Dropdown options={mockOptions} onChange={onChangeMock} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(mockOptions[0]);
|
||||
});
|
||||
|
||||
it("should call onChange with null when selection is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
render(
|
||||
<Dropdown
|
||||
options={mockOptions}
|
||||
clearable
|
||||
defaultValue={mockOptions[0]}
|
||||
onChange={onChangeMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId("dropdown-clear");
|
||||
await user.click(clearButton);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Controlled mode", () => {
|
||||
it.todo("should reflect external value changes");
|
||||
it.todo("should call onChange when selection changes");
|
||||
it.todo("should not update internal state when controlled");
|
||||
});
|
||||
|
||||
describe("Uncontrolled mode", () => {
|
||||
it.todo("should manage selection state internally");
|
||||
it.todo("should call onChange when selection changes");
|
||||
it.todo("should support defaultValue prop");
|
||||
});
|
||||
|
||||
describe("testId prop", () => {
|
||||
it("should apply custom testId to the root container", () => {
|
||||
render(<Dropdown options={mockOptions} testId="org-dropdown" />);
|
||||
|
||||
expect(screen.getByTestId("org-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cursor position preservation", () => {
|
||||
it("should keep menu open when clicking the input while dropdown is open", async () => {
|
||||
// Without a stateReducer, Downshift's default InputClick behavior
|
||||
// toggles the menu (closes it if already open). The stateReducer
|
||||
// should override this to keep the menu open so users can click
|
||||
// to reposition their cursor without losing the dropdown.
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
|
||||
// Click the input itself (simulates clicking to reposition cursor)
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
|
||||
// Menu should still be open — not toggled closed
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should still filter options correctly after typing with cursor fix", async () => {
|
||||
// Verifies that the direct onChange handler (which bypasses Downshift's
|
||||
// default onInputValueChange for cursor preservation) still updates
|
||||
// the search/filter state correctly.
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Option 1");
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,64 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { ReactElement } from "react";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: () => vi.fn(),
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
||||
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderUserActions = (props = { hasAvatar: true }) => {
|
||||
render(
|
||||
<UserActions
|
||||
user={
|
||||
props.hasAvatar
|
||||
? { avatar_url: "https://example.com/avatar.png" }
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Create mocks for all the hooks we need
|
||||
const useIsAuthedMock = vi
|
||||
.fn()
|
||||
@@ -38,9 +91,8 @@ describe("UserActions", () => {
|
||||
const onLogoutMock = vi.fn();
|
||||
|
||||
// Create a wrapper with MemoryRouter and renderWithProviders
|
||||
const renderWithRouter = (ui: ReactElement) => {
|
||||
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
const renderWithRouter = (ui: ReactElement) =>
|
||||
renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks to default values before each test
|
||||
@@ -61,29 +113,11 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
renderUserActions();
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||
renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
@@ -96,29 +130,31 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
renderUserActions();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is undefined and avatar is hovered", async () => {
|
||||
renderUserActions({ hasAvatar: false });
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu should NOT appear because user is undefined
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu even when user has no avatar_url", async () => {
|
||||
renderWithRouter(
|
||||
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT be able to access logout when user is not authenticated", async () => {
|
||||
@@ -133,15 +169,13 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
renderWithRouter(<UserActions />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should NOT be accessible when user is not authenticated
|
||||
expect(
|
||||
@@ -161,16 +195,12 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
const { unmount } = renderWithRouter(
|
||||
<UserActions onLogout={onLogoutMock} />,
|
||||
);
|
||||
const { unmount } = renderWithRouter(<UserActions />);
|
||||
|
||||
// Initially no user and not authenticated - menu should not appear
|
||||
let userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Unmount the first component
|
||||
unmount();
|
||||
@@ -188,10 +218,7 @@ describe("UserActions", () => {
|
||||
|
||||
// Render a new component with user prop and authentication
|
||||
renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// Component should render correctly
|
||||
@@ -199,12 +226,10 @@ describe("UserActions", () => {
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
|
||||
// Menu should now work with user defined and authenticated
|
||||
userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
const userActionsEl = screen.getByTestId("user-actions");
|
||||
await user.hover(userActionsEl);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
@@ -219,18 +244,13 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// Click to open menu
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
// Hover to open menu
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
|
||||
// Set authentication to false for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
@@ -246,14 +266,12 @@ describe("UserActions", () => {
|
||||
// Remove user prop - menu should disappear because user is no longer authenticated
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<UserActions onLogout={onLogoutMock} />
|
||||
<UserActions />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Context menu should NOT be visible when user becomes unauthenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should not be accessible
|
||||
expect(
|
||||
@@ -272,20 +290,168 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
isLoading={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu should still appear even when loading
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("context menu should default to user role", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Verify logout is present
|
||||
expect(screen.getByTestId("user-context-menu")).toHaveTextContent(
|
||||
"ACCOUNT_SETTINGS$LOGOUT",
|
||||
);
|
||||
// Verify nav items are present (e.g., settings nav items)
|
||||
expect(screen.getByTestId("user-context-menu")).toHaveTextContent(
|
||||
"SETTINGS$NAV_USER",
|
||||
);
|
||||
// Verify admin-only items are NOT present for user role
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByText("ORG$MANAGE_ORGANIZATION_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("ORG$MANAGE_ORGANIZATION"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should NOT show Team and Organization nav items when personal workspace is selected", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Team and Organization nav links should NOT be visible when no org is selected (personal workspace)
|
||||
expect(screen.queryByText("Team")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Organization")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu on hover", async () => {
|
||||
renderUserActions();
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
|
||||
// Menu is in DOM but hidden via CSS (opacity-0, pointer-events-none)
|
||||
expect(contextMenu.parentElement).toHaveClass("opacity-0");
|
||||
expect(contextMenu.parentElement).toHaveClass("pointer-events-none");
|
||||
|
||||
// Hover over the user actions area
|
||||
await user.hover(userActions);
|
||||
|
||||
// Menu should be visible on hover (CSS classes change via group-hover)
|
||||
expect(contextMenu).toBeVisible();
|
||||
});
|
||||
|
||||
it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => {
|
||||
renderUserActions();
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
const hoverBridgeContainer = contextMenu.parentElement;
|
||||
|
||||
// The hover bridge uses a ::before pseudo-element for diagonal mouse movement
|
||||
// This pseudo-element MUST have pointer-events-none to allow clicks through to menu items
|
||||
// The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks
|
||||
expect(hoverBridgeContainer?.className).toContain(
|
||||
"before:pointer-events-none",
|
||||
);
|
||||
});
|
||||
|
||||
describe("Org selector dropdown state reset when context menu hides", () => {
|
||||
// These tests verify that the org selector dropdown resets its internal
|
||||
// state (search text, open/closed) when the context menu hides and
|
||||
// reappears. Without this, stale state persists because the context
|
||||
// menu is hidden via CSS (opacity/pointer-events) rather than unmounted.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should reset org selector search text when context menu hides and reappears", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
|
||||
// Hover to show context menu
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for orgs to load and auto-select
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
|
||||
// Open dropdown and type search text
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "search text");
|
||||
expect(input).toHaveValue("search text");
|
||||
|
||||
// Unhover to hide context menu, then hover again
|
||||
await user.unhover(userActions);
|
||||
await user.hover(userActions);
|
||||
|
||||
// Org selector should be reset — showing selected org name, not search text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset dropdown to collapsed state when context menu hides and reappears", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
|
||||
// Hover to show context menu
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for orgs to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
|
||||
// Open dropdown and type to change its state
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Acme");
|
||||
expect(input).toHaveValue("Acme");
|
||||
|
||||
// Unhover to hide context menu, then hover again
|
||||
await user.unhover(userActions);
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for fresh component with org data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
|
||||
// Dropdown should be collapsed (closed) after reset
|
||||
expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
// No option elements should be rendered
|
||||
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UserAvatar } from "#/components/features/sidebar/user-avatar";
|
||||
|
||||
describe("UserAvatar", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
onClickMock.mockClear();
|
||||
});
|
||||
|
||||
it("(default) should render the placeholder avatar when the user is logged out", () => {
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
render(<UserAvatar />);
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
|
||||
const userAvatarContainer = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatarContainer);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display the user's avatar when available", () => {
|
||||
render(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
/>,
|
||||
);
|
||||
render(<UserAvatar avatarUrl="https://example.com/avatar.png" />);
|
||||
|
||||
expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -43,24 +21,20 @@ describe("UserAvatar", () => {
|
||||
});
|
||||
|
||||
it("should display a loading spinner instead of an avatar when isLoading is true", () => {
|
||||
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
|
||||
const { rerender } = render(<UserAvatar />);
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<UserAvatar onClick={onClickMock} isLoading />);
|
||||
rerender(<UserAvatar isLoading />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
isLoading
|
||||
/>,
|
||||
<UserAvatar avatarUrl="https://example.com/avatar.png" isLoading />,
|
||||
);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { BrowserObservation } from "#/types/v1/core/base/observation";
|
||||
import {
|
||||
BrowserObservation,
|
||||
GlobObservation,
|
||||
GrepObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
|
||||
describe("getObservationContent - BrowserObservation", () => {
|
||||
it("should return output content when available", () => {
|
||||
@@ -90,3 +94,212 @@ describe("getObservationContent - BrowserObservation", () => {
|
||||
expect(result).toBe("**Output:**\nPage loaded successfully");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getObservationContent - GlobObservation", () => {
|
||||
it("should display files found when glob matches files", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Found 2 files", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: ["/workspace/src/index.ts", "/workspace/src/app.ts"],
|
||||
pattern: "**/*.ts",
|
||||
search_path: "/workspace",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `**/*.ts`");
|
||||
expect(result).toContain("**Search Path:** `/workspace`");
|
||||
expect(result).toContain("**Files Found (2):**");
|
||||
expect(result).toContain("- `/workspace/src/index.ts`");
|
||||
expect(result).toContain("- `/workspace/src/app.ts`");
|
||||
});
|
||||
|
||||
it("should display no files found message when glob matches nothing", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "No files found", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: [],
|
||||
pattern: "**/*.xyz",
|
||||
search_path: "/workspace",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `**/*.xyz`");
|
||||
expect(result).toContain("**Result:** No files found.");
|
||||
});
|
||||
|
||||
it("should display error when glob operation fails", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Permission denied", cache_prompt: false }],
|
||||
is_error: true,
|
||||
files: [],
|
||||
pattern: "**/*",
|
||||
search_path: "/restricted",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("should indicate truncation when results exceed limit", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Found files", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: ["/workspace/file1.ts"],
|
||||
pattern: "**/*.ts",
|
||||
search_path: "/workspace",
|
||||
truncated: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Files Found (1+, truncated):**");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getObservationContent - GrepObservation", () => {
|
||||
it("should display matches found when grep finds results", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }],
|
||||
is_error: false,
|
||||
matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"],
|
||||
pattern: "fetchData",
|
||||
search_path: "/workspace",
|
||||
include_pattern: "*.ts",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `fetchData`");
|
||||
expect(result).toContain("**Search Path:** `/workspace`");
|
||||
expect(result).toContain("**Include:** `*.ts`");
|
||||
expect(result).toContain("**Matches (2):**");
|
||||
expect(result).toContain("- `/workspace/src/api.ts`");
|
||||
});
|
||||
|
||||
it("should display no matches found when grep finds nothing", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "No matches", cache_prompt: false }],
|
||||
is_error: false,
|
||||
matches: [],
|
||||
pattern: "nonExistentFunction",
|
||||
search_path: "/workspace",
|
||||
include_pattern: null,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `nonExistentFunction`");
|
||||
expect(result).toContain("**Result:** No matches found.");
|
||||
expect(result).not.toContain("**Include:**");
|
||||
});
|
||||
|
||||
it("should display error when grep operation fails", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }],
|
||||
is_error: true,
|
||||
matches: [],
|
||||
pattern: "[invalid",
|
||||
search_path: "/workspace",
|
||||
include_pattern: null,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Invalid regex pattern");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
/**
|
||||
* Creates a mock WebClientConfig with all required fields.
|
||||
* Use this helper to create test config objects with sensible defaults.
|
||||
*/
|
||||
export const createMockWebClientConfig = (
|
||||
overrides: Partial<WebClientConfig> = {},
|
||||
): WebClientConfig => ({
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
...overrides.feature_flags,
|
||||
},
|
||||
providers_configured: [],
|
||||
maintenance_start_time: null,
|
||||
auth_url: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: null,
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the useRevalidator hook from react-router
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useInviteMembersBatch", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should throw an error when organizationId is null", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useInviteMembersBatch(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Attempt to mutate without organizationId
|
||||
result.current.mutate({ emails: ["test@example.com"] });
|
||||
|
||||
// Should fail with an error about missing organizationId
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe("Organization ID is required");
|
||||
});
|
||||
});
|
||||
45
frontend/__tests__/hooks/mutation/use-remove-member.test.tsx
Normal file
45
frontend/__tests__/hooks/mutation/use-remove-member.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRemoveMember } from "#/hooks/mutation/use-remove-member";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the useRevalidator hook from react-router
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useRemoveMember", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should throw an error when organizationId is null", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRemoveMember(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Attempt to mutate without organizationId
|
||||
result.current.mutate({ userId: "user-123" });
|
||||
|
||||
// Should fail with an error about missing organizationId
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe("Organization ID is required");
|
||||
});
|
||||
});
|
||||
174
frontend/__tests__/hooks/query/use-organizations.test.tsx
Normal file
174
frontend/__tests__/hooks/query/use-organizations.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useOrganizations } from "#/hooks/query/use-organizations";
|
||||
import type { Organization } from "#/types/org";
|
||||
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
getOrganizations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useIsAuthed to return authenticated
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature)
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({ data: { app_mode: "saas" } }),
|
||||
}));
|
||||
|
||||
const mockGetOrganizations = vi.mocked(organizationService.getOrganizations);
|
||||
|
||||
function createMinimalOrg(
|
||||
id: string,
|
||||
name: string,
|
||||
is_personal?: boolean,
|
||||
): Organization {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
is_personal,
|
||||
contact_name: "",
|
||||
contact_email: "",
|
||||
conversation_expiration: 0,
|
||||
agent: "",
|
||||
default_max_iterations: 0,
|
||||
security_analyzer: "",
|
||||
confirmation_mode: false,
|
||||
default_llm_model: "",
|
||||
default_llm_api_key_for_byor: "",
|
||||
default_llm_base_url: "",
|
||||
remote_runtime_resource_factor: 0,
|
||||
enable_default_condenser: false,
|
||||
billing_margin: 0,
|
||||
enable_proactive_conversation_starters: false,
|
||||
sandbox_base_container_image: "",
|
||||
sandbox_runtime_container_image: "",
|
||||
org_version: 0,
|
||||
mcp_config: { tools: [], settings: {} },
|
||||
search_api_key: null,
|
||||
sandbox_api_key: null,
|
||||
max_budget_per_task: 0,
|
||||
enable_solvability_analysis: false,
|
||||
v1_enabled: false,
|
||||
credits: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useOrganizations", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sorts personal workspace first, then non-personal alphabetically by name", async () => {
|
||||
// API returns unsorted: Beta, Personal, Acme, All Hands
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: [
|
||||
createMinimalOrg("3", "Beta LLC", false),
|
||||
createMinimalOrg("1", "Personal Workspace", true),
|
||||
createMinimalOrg("2", "Acme Corp", false),
|
||||
createMinimalOrg("4", "All Hands AI", false),
|
||||
],
|
||||
currentOrgId: "1",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
const { organizations } = result.current.data!;
|
||||
expect(organizations).toHaveLength(4);
|
||||
expect(organizations[0].id).toBe("1");
|
||||
expect(organizations[0].is_personal).toBe(true);
|
||||
expect(organizations[0].name).toBe("Personal Workspace");
|
||||
expect(organizations[1].name).toBe("Acme Corp");
|
||||
expect(organizations[2].name).toBe("All Hands AI");
|
||||
expect(organizations[3].name).toBe("Beta LLC");
|
||||
});
|
||||
|
||||
it("treats missing is_personal as false and sorts by name", async () => {
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: [
|
||||
createMinimalOrg("1", "Zebra Org"), // no is_personal
|
||||
createMinimalOrg("2", "Alpha Org", true), // personal first
|
||||
createMinimalOrg("3", "Mango Org"), // no is_personal
|
||||
],
|
||||
currentOrgId: "2",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
const { organizations } = result.current.data!;
|
||||
expect(organizations[0].id).toBe("2");
|
||||
expect(organizations[0].is_personal).toBe(true);
|
||||
expect(organizations[1].name).toBe("Mango Org");
|
||||
expect(organizations[2].name).toBe("Zebra Org");
|
||||
});
|
||||
|
||||
it("handles missing name by treating as empty string for sort", async () => {
|
||||
const orgWithName = createMinimalOrg("2", "Beta", false);
|
||||
const orgNoName = { ...createMinimalOrg("1", "Alpha", false) };
|
||||
delete (orgNoName as Record<string, unknown>).name;
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: [orgWithName, orgNoName] as Organization[],
|
||||
currentOrgId: "1",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
const { organizations } = result.current.data!;
|
||||
// undefined name is coerced to ""; "" sorts before "Beta"
|
||||
expect(organizations[0].id).toBe("1");
|
||||
expect(organizations[1].id).toBe("2");
|
||||
expect(organizations[1].name).toBe("Beta");
|
||||
});
|
||||
|
||||
it("does not mutate the original array from the API", async () => {
|
||||
const apiOrgs = [
|
||||
createMinimalOrg("2", "Acme", false),
|
||||
createMinimalOrg("1", "Personal", true),
|
||||
];
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: apiOrgs,
|
||||
currentOrgId: "1",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
// Hook sorts a copy ([...data]), so API order unchanged
|
||||
expect(apiOrgs[0].id).toBe("2");
|
||||
expect(apiOrgs[1].id).toBe("1");
|
||||
// Returned data is sorted
|
||||
expect(result.current.data!.organizations[0].id).toBe("1");
|
||||
expect(result.current.data!.organizations[1].id).toBe("2");
|
||||
});
|
||||
});
|
||||
134
frontend/__tests__/hooks/use-org-type-and-access.test.tsx
Normal file
134
frontend/__tests__/hooks/use-org-type-and-access.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-organizations", () => ({
|
||||
useOrganizations: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { useOrganizations } from "#/hooks/query/use-organizations";
|
||||
|
||||
const mockUseSelectedOrganizationId = vi.mocked(useSelectedOrganizationId);
|
||||
const mockUseOrganizations = vi.mocked(useOrganizations);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("useOrgTypeAndAccess", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return false for all booleans when no organization is selected", async () => {
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: null,
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [], currentOrgId: null },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toBeUndefined();
|
||||
expect(result.current.isPersonalOrg).toBe(false);
|
||||
expect(result.current.isTeamOrg).toBe(false);
|
||||
expect(result.current.canViewOrgRoutes).toBe(false);
|
||||
expect(result.current.organizationId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return isPersonalOrg=true and isTeamOrg=false for personal org", async () => {
|
||||
const personalOrg = { id: "org-1", is_personal: true, name: "Personal" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-1",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [personalOrg], currentOrgId: "org-1" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toEqual(personalOrg);
|
||||
expect(result.current.isPersonalOrg).toBe(true);
|
||||
expect(result.current.isTeamOrg).toBe(false);
|
||||
expect(result.current.canViewOrgRoutes).toBe(false);
|
||||
expect(result.current.organizationId).toBe("org-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return isPersonalOrg=false and isTeamOrg=true for team org", async () => {
|
||||
const teamOrg = { id: "org-2", is_personal: false, name: "Team" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-2",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [teamOrg], currentOrgId: "org-2" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toEqual(teamOrg);
|
||||
expect(result.current.isPersonalOrg).toBe(false);
|
||||
expect(result.current.isTeamOrg).toBe(true);
|
||||
expect(result.current.canViewOrgRoutes).toBe(true);
|
||||
expect(result.current.organizationId).toBe("org-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return canViewOrgRoutes=true only when isTeamOrg AND organizationId is truthy", async () => {
|
||||
const teamOrg = { id: "org-3", is_personal: false, name: "Team" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-3",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [teamOrg], currentOrgId: "org-3" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isTeamOrg).toBe(true);
|
||||
expect(result.current.organizationId).toBe("org-3");
|
||||
expect(result.current.canViewOrgRoutes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should treat undefined is_personal field as team org", async () => {
|
||||
// Organization without is_personal field (undefined)
|
||||
const orgWithoutPersonalField = { id: "org-4", name: "Unknown Type" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-4",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [orgWithoutPersonalField], currentOrgId: "org-4" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toEqual(orgWithoutPersonalField);
|
||||
expect(result.current.isPersonalOrg).toBe(false);
|
||||
expect(result.current.isTeamOrg).toBe(true);
|
||||
expect(result.current.canViewOrgRoutes).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
frontend/__tests__/hooks/use-permission.test.tsx
Normal file
98
frontend/__tests__/hooks/use-permission.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
import { rolePermissions } from "#/utils/org/permissions";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
|
||||
describe("usePermission", () => {
|
||||
const setup = (role: OrganizationUserRole) =>
|
||||
renderHook(() => usePermission(role)).result.current;
|
||||
|
||||
describe("hasPermission", () => {
|
||||
it("returns true when the role has the permission", () => {
|
||||
const { hasPermission } = setup("admin");
|
||||
|
||||
expect(hasPermission("invite_user_to_organization")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the role does not have the permission", () => {
|
||||
const { hasPermission } = setup("member");
|
||||
|
||||
expect(hasPermission("invite_user_to_organization")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rolePermissions integration", () => {
|
||||
it("matches the permissions defined for the role", () => {
|
||||
const { hasPermission } = setup("member");
|
||||
|
||||
rolePermissions.member.forEach((permission) => {
|
||||
expect(hasPermission(permission)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("change_user_role permission behavior", () => {
|
||||
const run = (
|
||||
activeUserRole: OrganizationUserRole,
|
||||
targetUserId: string,
|
||||
targetRole: OrganizationUserRole,
|
||||
activeUserId = "123",
|
||||
) => {
|
||||
const { hasPermission } = renderHook(() =>
|
||||
usePermission(activeUserRole),
|
||||
).result.current;
|
||||
|
||||
// users can't change their own roles
|
||||
if (activeUserId === targetUserId) return false;
|
||||
|
||||
return hasPermission(`change_user_role:${targetRole}`);
|
||||
};
|
||||
|
||||
describe("member role", () => {
|
||||
it("cannot change any roles", () => {
|
||||
expect(run("member", "u2", "member")).toBe(false);
|
||||
expect(run("member", "u2", "admin")).toBe(false);
|
||||
expect(run("member", "u2", "owner")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin role", () => {
|
||||
it("cannot change owner role", () => {
|
||||
expect(run("admin", "u2", "owner")).toBe(false);
|
||||
});
|
||||
|
||||
it("can change member or admin roles", () => {
|
||||
expect(run("admin", "u2", "member")).toBe(
|
||||
rolePermissions.admin.includes("change_user_role:member")
|
||||
);
|
||||
expect(run("admin", "u2", "admin")).toBe(
|
||||
rolePermissions.admin.includes("change_user_role:admin")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("owner role", () => {
|
||||
it("can change owner, admin, and member roles", () => {
|
||||
expect(run("owner", "u2", "admin")).toBe(
|
||||
rolePermissions.owner.includes("change_user_role:admin"),
|
||||
);
|
||||
|
||||
expect(run("owner", "u2", "member")).toBe(
|
||||
rolePermissions.owner.includes("change_user_role:member"),
|
||||
);
|
||||
|
||||
expect(run("owner", "u2", "owner")).toBe(
|
||||
rolePermissions.owner.includes("change_user_role:owner"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("self role change", () => {
|
||||
it("is always disallowed", () => {
|
||||
expect(run("owner", "u2", "member", "u2")).toBe(false);
|
||||
expect(run("admin", "u2", "member", "u2")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,18 +6,54 @@ import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
|
||||
|
||||
// Mock useOrgTypeAndAccess
|
||||
const mockOrgTypeAndAccess = vi.hoisted(() => ({
|
||||
isPersonalOrg: false,
|
||||
isTeamOrg: false,
|
||||
organizationId: null as string | null,
|
||||
selectedOrg: null,
|
||||
canViewOrgRoutes: false,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-org-type-and-access", () => ({
|
||||
useOrgTypeAndAccess: () => mockOrgTypeAndAccess,
|
||||
}));
|
||||
|
||||
// Mock useMe
|
||||
const mockMe = vi.hoisted(() => ({
|
||||
data: null as { role: string } | null | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-me", () => ({
|
||||
useMe: () => mockMe,
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
|
||||
const mockConfig = (
|
||||
appMode: "saas" | "oss",
|
||||
hideLlmSettings = false,
|
||||
enableBilling = true,
|
||||
) => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
app_mode: appMode,
|
||||
feature_flags: { hide_llm_settings: hideLlmSettings },
|
||||
feature_flags: {
|
||||
hide_llm_settings: hideLlmSettings,
|
||||
enable_billing: enableBilling,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockConfigWithFeatureFlags = (
|
||||
appMode: "saas" | "oss",
|
||||
featureFlags: Partial<WebClientFeatureFlags>,
|
||||
@@ -25,7 +61,7 @@ const mockConfigWithFeatureFlags = (
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
app_mode: appMode,
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true, // Enable billing by default so it's not hidden
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -41,19 +77,38 @@ const mockConfigWithFeatureFlags = (
|
||||
describe("useSettingsNavItems", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.restoreAllMocks();
|
||||
// Reset mock state
|
||||
mockOrgTypeAndAccess.isPersonalOrg = false;
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = null;
|
||||
mockOrgTypeAndAccess.selectedOrg = null;
|
||||
mockOrgTypeAndAccess.canViewOrgRoutes = false;
|
||||
mockMe.data = null;
|
||||
});
|
||||
|
||||
it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => {
|
||||
it("should return SAAS_NAV_ITEMS minus billing/org/org-members when userRole is 'member'", async () => {
|
||||
mockConfig("saas");
|
||||
mockMe.data = { role: "member" };
|
||||
mockOrgTypeAndAccess.organizationId = "org-1";
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual(SAAS_NAV_ITEMS);
|
||||
expect(result.current).toEqual(
|
||||
SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/billing" &&
|
||||
item.to !== "/settings/org" &&
|
||||
item.to !== "/settings/org-members",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => {
|
||||
mockConfig("oss");
|
||||
mockMe.data = { role: "admin" };
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -63,6 +118,8 @@ describe("useSettingsNavItems", () => {
|
||||
|
||||
it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => {
|
||||
mockConfig("saas", true);
|
||||
mockMe.data = { role: "admin" };
|
||||
mockOrgTypeAndAccess.organizationId = "org-1";
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -72,7 +129,163 @@ describe("useSettingsNavItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("org-type and role-based filtering", () => {
|
||||
it("should include org routes by default for team org admin", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load (check that any SAAS item is present)
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be included for team org admin
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("should hide org routes when isPersonalOrg is true", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isPersonalOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load (check that any SAAS item is present)
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be filtered out for personal orgs
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should hide org routes when user role is member", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "member" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be hidden for members
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should hide org routes when no organization is selected", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.isPersonalOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = null;
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be hidden when no org is selected
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should hide billing route when isTeamOrg is true", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Billing should be hidden for team orgs
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/billing"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show billing route for personal org", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isPersonalOrg = true;
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Billing should be visible for personal orgs
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/billing"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hide page feature flags", () => {
|
||||
beforeEach(() => {
|
||||
// Set up user as admin with org context so billing is accessible
|
||||
mockMe.data = { role: "admin" };
|
||||
mockOrgTypeAndAccess.isPersonalOrg = true; // Personal org shows billing
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = "org-1";
|
||||
});
|
||||
|
||||
it("should filter out '/settings/user' when hide_users_page is true", async () => {
|
||||
mockConfigWithFeatureFlags("saas", { hide_users_page: true });
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import i18n from "../../src/i18n";
|
||||
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
describe("Translations", () => {
|
||||
it("should render translated text", () => {
|
||||
i18n.changeLanguage("en");
|
||||
renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<AccountSettingsContextMenu onLogout={() => {}} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not attempt to load unsupported language codes", async () => {
|
||||
// Test that the configuration prevents 404 errors by not attempting to load
|
||||
// unsupported language codes like 'en-US@posix'
|
||||
const originalLanguage = i18n.language;
|
||||
|
||||
try {
|
||||
// With nonExplicitSupportedLngs: false, i18next will not attempt to load
|
||||
// unsupported language codes, preventing 404 errors
|
||||
|
||||
// Test with a language code that includes region but is not in supportedLngs
|
||||
await i18n.changeLanguage("en-US@posix");
|
||||
|
||||
// Since "en-US@posix" is not in supportedLngs and nonExplicitSupportedLngs is false,
|
||||
// i18next should fall back to the fallbackLng ("en")
|
||||
expect(i18n.language).toBe("en");
|
||||
|
||||
// Test another unsupported region code
|
||||
await i18n.changeLanguage("ja-JP");
|
||||
|
||||
// Even with nonExplicitSupportedLngs: false, i18next still falls back to base language
|
||||
// if it exists in supportedLngs, but importantly, it won't make a 404 request first
|
||||
expect(i18n.language).toBe("ja");
|
||||
|
||||
// Test that supported languages still work
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(i18n.language).toBe("ja");
|
||||
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
expect(i18n.language).toBe("zh-CN");
|
||||
|
||||
} finally {
|
||||
// Restore the original language
|
||||
await i18n.changeLanguage(originalLanguage);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have proper i18n configuration", () => {
|
||||
// Test that the i18n instance has the expected configuration
|
||||
expect(i18n.options.supportedLngs).toBeDefined();
|
||||
|
||||
// nonExplicitSupportedLngs should be false to prevent 404 errors
|
||||
expect(i18n.options.nonExplicitSupportedLngs).toBe(false);
|
||||
|
||||
// fallbackLng can be a string or array, check if it includes "en"
|
||||
const fallbackLng = i18n.options.fallbackLng;
|
||||
if (Array.isArray(fallbackLng)) {
|
||||
expect(fallbackLng).toContain("en");
|
||||
} else {
|
||||
expect(fallbackLng).toBe("en");
|
||||
}
|
||||
|
||||
// Test that supported languages include both base and region-specific codes
|
||||
const supportedLngs = i18n.options.supportedLngs as string[];
|
||||
expect(supportedLngs).toContain("en");
|
||||
expect(supportedLngs).toContain("zh-CN");
|
||||
expect(supportedLngs).toContain("zh-TW");
|
||||
expect(supportedLngs).toContain("ko-KR");
|
||||
});
|
||||
});
|
||||
10
frontend/__tests__/routes/api-keys.test.tsx
Normal file
10
frontend/__tests__/routes/api-keys.test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clientLoader } from "#/routes/api-keys";
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import AppSettingsScreen, { clientLoader } from "#/routes/app-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
@@ -18,6 +18,14 @@ const renderAppSettingsScreen = () =>
|
||||
),
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the screen", () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
367
frontend/__tests__/routes/billing.test.tsx
Normal file
367
frontend/__tests__/routes/billing.test.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import BillingSettingsScreen, { clientLoader } from "#/routes/billing";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import * as orgStore from "#/stores/selected-organization-store";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackCreditsPurchased: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useBalance hook
|
||||
const mockUseBalance = vi.fn();
|
||||
vi.mock("#/hooks/query/use-balance", () => ({
|
||||
useBalance: () => mockUseBalance(),
|
||||
}));
|
||||
|
||||
// Mock useCreateStripeCheckoutSession hook
|
||||
vi.mock(
|
||||
"#/hooks/mutation/stripe/use-create-stripe-checkout-session",
|
||||
() => ({
|
||||
useCreateStripeCheckoutSession: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
describe("Billing Route", () => {
|
||||
const { mockQueryClient } = vi.hoisted(() => ({
|
||||
mockQueryClient: (() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
})(),
|
||||
}));
|
||||
|
||||
// Mock queryClient to use our test instance
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
const setupSaasMode = (featureFlags = {}) => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
...featureFlags,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueryClient.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("clientLoader cache key", () => {
|
||||
it("should use the 'web-client-config' query key to read cached config", async () => {
|
||||
// Arrange: pre-populate the cache under the canonical key
|
||||
seedActiveUser({ role: "admin" });
|
||||
const cachedConfig = {
|
||||
app_mode: "saas" as const,
|
||||
posthog_client_key: "test",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
};
|
||||
mockQueryClient.setQueryData(["web-client-config"], cachedConfig);
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
// Act: invoke the clientLoader directly
|
||||
const result = await clientLoader();
|
||||
|
||||
// Assert: the loader should have found the cached config and NOT called getConfig
|
||||
expect(getConfigSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull(); // admin with billing enabled = no redirect
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should redirect members to /settings/user when accessing billing directly", async () => {
|
||||
// Arrange
|
||||
setupSaasMode();
|
||||
seedActiveUser({ role: "member" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should be redirected to user settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow admins to access billing route", async () => {
|
||||
// Arrange
|
||||
setupSaasMode();
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should stay on billing page (component renders PaymentForm)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("user-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow owners to access billing route", async () => {
|
||||
// Arrange
|
||||
setupSaasMode();
|
||||
seedActiveUser({ role: "owner" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should stay on billing page
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("user-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect when user is undefined (no org selected)", async () => {
|
||||
// Arrange: no org selected, so getActiveOrganizationUser returns undefined
|
||||
setupSaasMode();
|
||||
// Explicitly clear org store so getActiveOrganizationUser returns undefined
|
||||
orgStore.useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should be redirected to user settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect all users when enable_billing is false", async () => {
|
||||
// Arrange: enable_billing=false means billing is hidden for everyone
|
||||
setupSaasMode({ enable_billing: false });
|
||||
seedActiveUser({ role: "owner" }); // Even owners should be redirected
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should be redirected to user settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentForm permission behavior", () => {
|
||||
beforeEach(() => {
|
||||
mockUseBalance.mockReturnValue({
|
||||
data: "150.00",
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable input and button when isDisabled is true, but show balance", async () => {
|
||||
// Arrange & Act
|
||||
render(<PaymentForm isDisabled />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - balance is visible
|
||||
const balance = screen.getByTestId("user-balance");
|
||||
expect(balance).toBeInTheDocument();
|
||||
expect(balance).toHaveTextContent("$150.00");
|
||||
|
||||
// Assert - input is disabled
|
||||
const topUpInput = screen.getByTestId("top-up-input");
|
||||
expect(topUpInput).toBeDisabled();
|
||||
|
||||
// Assert - button is disabled
|
||||
const submitButton = screen.getByRole("button");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable input and button when isDisabled is false", async () => {
|
||||
// Arrange & Act
|
||||
render(<PaymentForm isDisabled={false} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - input is enabled
|
||||
const topUpInput = screen.getByTestId("top-up-input");
|
||||
expect(topUpInput).not.toBeDisabled();
|
||||
|
||||
// Assert - button starts disabled (no amount entered) but is NOT
|
||||
// permanently disabled by the isDisabled prop
|
||||
const submitButton = screen.getByRole("button");
|
||||
// The button is disabled because no valid amount is entered, not because of isDisabled
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import GitSettingsScreen, { clientLoader } from "#/routes/git-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
@@ -13,7 +13,6 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
|
||||
const VALID_OSS_CONFIG: WebClientConfig = {
|
||||
app_mode: "oss",
|
||||
@@ -657,3 +656,10 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,7 +541,7 @@ describe("Settings 404", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Setup Payment modal", () => {
|
||||
describe("New user welcome toast", () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
@@ -593,7 +593,7 @@ describe("Setup Payment modal", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should only render if SaaS mode and is new user", async () => {
|
||||
it("should not show the setup payment modal (removed) in SaaS mode for new users", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
is_new_user: true,
|
||||
@@ -603,9 +603,9 @@ describe("Setup Payment modal", () => {
|
||||
|
||||
await screen.findByTestId("root-layout");
|
||||
|
||||
const setupPaymentModal = await screen.findByTestId(
|
||||
"proceed-to-stripe-button",
|
||||
);
|
||||
expect(setupPaymentModal).toBeInTheDocument();
|
||||
// SetupPaymentModal was removed; verify it no longer renders
|
||||
expect(
|
||||
screen.queryByTestId("proceed-to-stripe-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,18 +9,33 @@ import {
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
const mockUseSearchParams = vi.fn();
|
||||
vi.mock("react-router", () => ({
|
||||
useSearchParams: () => mockUseSearchParams(),
|
||||
}));
|
||||
vi.mock("react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: () => mockUseSearchParams(),
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseIsAuthed = vi.fn();
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
const mockUseConfig = vi.fn();
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
function buildSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -32,12 +47,57 @@ function buildSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
};
|
||||
}
|
||||
|
||||
function renderLlmSettingsScreen() {
|
||||
function buildOrganizationMember(
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember {
|
||||
return {
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "owner@example.com",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderLlmSettingsScreen(
|
||||
options: {
|
||||
appMode?: "oss" | "saas";
|
||||
organizationId?: string;
|
||||
meData?: OrganizationMember;
|
||||
} = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const organizationId = options.organizationId ?? "1";
|
||||
const appMode = options.appMode ?? "oss";
|
||||
|
||||
useSelectedOrganizationStore.setState({ organizationId });
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: appMode },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
if (appMode === "saas") {
|
||||
queryClient.setQueryData(
|
||||
["organizations", organizationId, "me"],
|
||||
options.meData ?? buildOrganizationMember({ org_id: organizationId }),
|
||||
);
|
||||
}
|
||||
|
||||
return render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -53,6 +113,11 @@ beforeEach(() => {
|
||||
vi.fn(),
|
||||
]);
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
});
|
||||
|
||||
describe("LlmSettingsScreen", () => {
|
||||
@@ -64,6 +129,7 @@ describe("LlmSettingsScreen", () => {
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
expect(screen.getByTestId("sdk-settings-llm.model")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sdk-settings-llm.api_key")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sdk-settings-critic.enabled")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("sdk-settings-critic.mode"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -127,6 +193,20 @@ describe("LlmSettingsScreen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the save button for read-only SaaS members", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
|
||||
|
||||
renderLlmSettingsScreen({
|
||||
appMode: "saas",
|
||||
organizationId: "2",
|
||||
meData: buildOrganizationMember({ org_id: "2", role: "member" }),
|
||||
});
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
expect(screen.queryByTestId("save-button")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sdk-settings-llm.model")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows a fallback message when sdk settings schema is unavailable", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({ sdk_settings_schema: null }),
|
||||
|
||||
954
frontend/__tests__/routes/manage-org.test.tsx
Normal file
954
frontend/__tests__/routes/manage-org.test.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { selectOrganization } from "test-utils";
|
||||
import ManageOrg from "#/routes/manage-org";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import {
|
||||
resetOrgMockData,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
INITIAL_MOCK_ORGS,
|
||||
} from "#/mocks/org-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
const mockQueryClient = vi.hoisted(() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient();
|
||||
});
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
function ManageOrgWithPortalRoot() {
|
||||
return (
|
||||
<div>
|
||||
<ManageOrg />
|
||||
<div data-testid="portal-root" id="portal-root" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RouteStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <div data-testid="home-screen" />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
// @ts-expect-error - type mismatch
|
||||
loader: clientLoader,
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
HydrateFallback: () => <div>Loading...</div>,
|
||||
children: [
|
||||
{
|
||||
Component: ManageOrgWithPortalRoot,
|
||||
path: "/settings/org",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderManageOrg = () =>
|
||||
render(<RouteStub initialEntries={["/settings/org"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
||||
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
...(await vi.importActual("react-router")),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
describe("Manage Org Route", () => {
|
||||
const getMeSpy = vi.spyOn(organizationService, "getMe");
|
||||
|
||||
// Test data constants
|
||||
const TEST_USERS: Record<"OWNER" | "ADMIN", OrganizationMember> = {
|
||||
OWNER: {
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "owner",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
},
|
||||
ADMIN: {
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to set up user mock
|
||||
const setupUserMock = (userData: {
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
llm_api_key: string;
|
||||
max_iterations: number;
|
||||
llm_model: string;
|
||||
llm_api_key_for_byor: string | null;
|
||||
llm_base_url: string;
|
||||
status: "active" | "invited" | "inactive";
|
||||
}) => {
|
||||
getMeSpy.mockResolvedValue(userData);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Set Zustand store to a team org so clientLoader's org route protection allows access
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
// Seed organizations into the module-level queryClient used by clientLoader
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
queryClient = new QueryClient();
|
||||
// Pre-seed organizations so org selector renders immediately (avoids flaky race with API fetch)
|
||||
queryClient.setQueryData(["organizations"], {
|
||||
items: INITIAL_MOCK_ORGS,
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true, // Enable billing by default so billing UI is shown
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Set default mock for user (owner role has all permissions)
|
||||
setupUserMock(TEST_USERS.OWNER);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset organization mock data to ensure clean state between tests
|
||||
resetOrgMockData();
|
||||
// Reset Zustand store to ensure clean state between tests
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
// Clear module-level queryClient used by clientLoader
|
||||
mockQueryClient.clear();
|
||||
// Clear test queryClient
|
||||
queryClient?.clear();
|
||||
});
|
||||
|
||||
it("should render the available credits", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
const credits = screen.getByTestId("available-credits");
|
||||
expect(credits).toHaveTextContent("100");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render account details", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
const orgName = screen.getByTestId("org-name");
|
||||
expect(orgName).toHaveTextContent("Personal Workspace");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to add credits", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument();
|
||||
// Simulate adding credits — wait for permissions-dependent button
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await userEvent.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
const amountInput = within(addCreditsForm).getByTestId("amount-input");
|
||||
const nextButton = within(addCreditsForm).getByRole("button", {
|
||||
name: /next/i,
|
||||
});
|
||||
|
||||
await userEvent.type(amountInput, "1000");
|
||||
await userEvent.click(nextButton);
|
||||
|
||||
// expect redirect to payment page
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should close the modal when clicking cancel", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument();
|
||||
// Simulate adding credits — wait for permissions-dependent button
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await userEvent.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
const cancelButton = within(addCreditsForm).getByRole("button", {
|
||||
name: /close/i,
|
||||
});
|
||||
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument();
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("AddCreditsModal", () => {
|
||||
const openAddCreditsModal = async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await user.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
return { user, addCreditsForm };
|
||||
};
|
||||
|
||||
describe("Button State Management", () => {
|
||||
it("should enable submit button initially when modal opens", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains invalid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains valid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "100");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button after validation error is shown", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Attributes & Placeholder", () => {
|
||||
it("should have min attribute set to 10", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("min", "10");
|
||||
});
|
||||
|
||||
it("should have max attribute set to 25000", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("max", "25000");
|
||||
});
|
||||
|
||||
it("should have step attribute set to 1", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("step", "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Message Display", () => {
|
||||
it("should not display error message initially when modal opens", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount above maximum", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting decimal value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "50.5");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should replace error message when submitting different invalid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission Behavior", () => {
|
||||
it("should prevent submission when amount is invalid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call createCheckoutSession with correct amount when valid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call createCheckoutSession when validation fails", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Verify mutation was not called
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_NEGATIVE_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should close modal on successful submission", async () => {
|
||||
const createCheckoutSessionSpy = vi
|
||||
.spyOn(BillingService, "createCheckoutSession")
|
||||
.mockResolvedValue("https://checkout.stripe.com/test-session");
|
||||
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("add-credits-form"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow API call when validation passes and clear any previous errors", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
// First submit invalid value
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Then submit valid value
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "100");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle zero value correctly", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "0");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input correctly", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
// Number inputs typically don't accept spaces, but test the behavior
|
||||
await user.type(amountInput, " ");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should not call API (empty/invalid input)
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show add credits option for ADMIN role", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI)
|
||||
|
||||
// Verify credits are shown
|
||||
await waitFor(() => {
|
||||
const credits = screen.getByTestId("available-credits");
|
||||
expect(credits).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify add credits button is present (admins can add credits)
|
||||
const addButton = screen.getByText(/add/i);
|
||||
expect(addButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("actions", () => {
|
||||
it("should be able to update the organization name", async () => {
|
||||
const updateOrgNameSpy = vi.spyOn(
|
||||
organizationService,
|
||||
"updateOrganization",
|
||||
);
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas", // required to enable getMe
|
||||
}),
|
||||
);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
const orgName = screen.getByTestId("org-name");
|
||||
await waitFor(() =>
|
||||
expect(orgName).toHaveTextContent("Personal Workspace"),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("update-org-name-form"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const changeOrgNameButton = within(orgName).getByRole("button", {
|
||||
name: /change/i,
|
||||
});
|
||||
await userEvent.click(changeOrgNameButton);
|
||||
|
||||
const orgNameForm = screen.getByTestId("update-org-name-form");
|
||||
const orgNameInput = within(orgNameForm).getByRole("textbox");
|
||||
const saveButton = within(orgNameForm).getByRole("button", {
|
||||
name: /save/i,
|
||||
});
|
||||
|
||||
await userEvent.type(orgNameInput, "New Org Name");
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
expect(updateOrgNameSpy).toHaveBeenCalledWith({
|
||||
orgId: "1",
|
||||
name: "New Org Name",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("update-org-name-form"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(orgName).toHaveTextContent("New Org Name");
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT allow roles other than owners to change org name", async () => {
|
||||
// Set admin role before rendering
|
||||
setupUserMock(TEST_USERS.ADMIN);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI)
|
||||
|
||||
const orgName = screen.getByTestId("org-name");
|
||||
const changeOrgNameButton = within(orgName).queryByRole("button", {
|
||||
name: /change/i,
|
||||
});
|
||||
expect(changeOrgNameButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT allow roles other than owners to delete an organization", async () => {
|
||||
setupUserMock(TEST_USERS.ADMIN);
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas", // required to enable getMe
|
||||
}),
|
||||
);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI)
|
||||
|
||||
const deleteOrgButton = screen.queryByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
expect(deleteOrgButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be able to delete an organization", async () => {
|
||||
const deleteOrgSpy = vi.spyOn(organizationService, "deleteOrganization");
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("delete-org-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const deleteOrgButton = await waitFor(() =>
|
||||
screen.getByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(deleteOrgButton);
|
||||
|
||||
const deleteConfirmation = screen.getByTestId("delete-org-confirmation");
|
||||
const confirmButton = within(deleteConfirmation).getByRole("button", {
|
||||
name: /BUTTON\$CONFIRM/i,
|
||||
});
|
||||
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(deleteOrgSpy).toHaveBeenCalledWith({ orgId: "1" });
|
||||
expect(
|
||||
screen.queryByTestId("delete-org-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// expect to have navigated to home screen
|
||||
await screen.findByTestId("home-screen");
|
||||
});
|
||||
|
||||
it.todo("should be able to update the organization billing info");
|
||||
});
|
||||
|
||||
describe("Role-based delete organization permission behavior", () => {
|
||||
it("should show delete organization button when user has canDeleteOrganization permission (Owner role)", async () => {
|
||||
setupUserMock(TEST_USERS.OWNER);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
const deleteButton = await screen.findByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
expect(deleteButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not show delete organization button when user lacks canDeleteOrganization permission ('Admin' role)", async () => {
|
||||
setupUserMock({
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
const deleteButton = screen.queryByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
|
||||
expect(deleteButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show delete organization button when user lacks canDeleteOrganization permission ('Member' role)", async () => {
|
||||
setupUserMock({
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Members lack view_billing permission, so the clientLoader redirects away from /settings/org
|
||||
renderManageOrg();
|
||||
|
||||
// The manage-org screen should NOT be accessible — clientLoader redirects
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("manage-org-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open delete confirmation modal when delete button is clicked (with permission)", async () => {
|
||||
setupUserMock(TEST_USERS.OWNER);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("delete-org-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const deleteButton = await screen.findByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
expect(screen.getByTestId("delete-org-confirmation")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable_billing feature flag", () => {
|
||||
it("should show credits section when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("available-credits")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should show organization name section when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("org-name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should show Add Credits button when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const addButton = screen.getByText(/add/i);
|
||||
expect(addButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should hide all billing-related elements when enable_billing is false", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("available-credits"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/add/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
1062
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
1062
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
10
frontend/__tests__/routes/mcp-settings.test.tsx
Normal file
10
frontend/__tests__/routes/mcp-settings.test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clientLoader } from "#/routes/mcp-settings";
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { screen, act } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import PlannerTab from "#/routes/planner-tab";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
@@ -12,8 +12,15 @@ vi.mock("#/hooks/use-handle-plan-click", () => ({
|
||||
}));
|
||||
|
||||
describe("PlannerTab", () => {
|
||||
const originalRAF = global.requestAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Make requestAnimationFrame execute synchronously for testing
|
||||
global.requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
};
|
||||
// Reset store state to defaults
|
||||
useConversationStore.setState({
|
||||
planContent: null,
|
||||
@@ -21,6 +28,10 @@ describe("PlannerTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.requestAnimationFrame = originalRAF;
|
||||
});
|
||||
|
||||
describe("Create a plan button", () => {
|
||||
it("should be enabled when conversation mode is 'code'", () => {
|
||||
// Arrange
|
||||
@@ -52,4 +63,71 @@ describe("PlannerTab", () => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto-scroll behavior", () => {
|
||||
it("should scroll to bottom when plan content is updated", () => {
|
||||
// Arrange
|
||||
const scrollTopSetter = vi.fn();
|
||||
const mockScrollHeight = 500;
|
||||
|
||||
// Mock scroll properties on HTMLElement prototype
|
||||
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"scrollHeight",
|
||||
);
|
||||
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"scrollTop",
|
||||
);
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
get: () => mockScrollHeight,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
|
||||
get: () => 0,
|
||||
set: scrollTopSetter,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Render with initial plan content
|
||||
useConversationStore.setState({
|
||||
planContent: "# Initial Plan",
|
||||
conversationMode: "plan",
|
||||
});
|
||||
|
||||
renderWithProviders(<PlannerTab />);
|
||||
|
||||
// Clear calls from initial render
|
||||
scrollTopSetter.mockClear();
|
||||
|
||||
// Act - Update plan content which should trigger auto-scroll
|
||||
act(() => {
|
||||
useConversationStore.setState({
|
||||
planContent: "# Updated Plan\n\nMore content added here.",
|
||||
});
|
||||
});
|
||||
|
||||
// Assert - scrollTop should be set to scrollHeight
|
||||
expect(scrollTopSetter).toHaveBeenCalledWith(mockScrollHeight);
|
||||
} finally {
|
||||
// Restore original descriptors
|
||||
if (originalScrollHeightDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"scrollHeight",
|
||||
originalScrollHeightDescriptor,
|
||||
);
|
||||
}
|
||||
if (originalScrollTopDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"scrollTop",
|
||||
originalScrollTopDescriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SecretsSettingsScreen from "#/routes/secrets-settings";
|
||||
import SecretsSettingsScreen, { clientLoader } from "#/routes/secrets-settings";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import * as orgStore from "#/stores/selected-organization-store";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
|
||||
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
|
||||
{
|
||||
@@ -66,6 +69,75 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
|
||||
it("should allow members to access secrets settings (all roles have manage_secrets)", async () => {
|
||||
// Arrange
|
||||
seedActiveUser({ role: "member" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SecretsSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/secrets",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should stay on secrets settings page (not redirected)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("secrets-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("user-settings-screen")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the secrets settings screen", () => {
|
||||
renderSecretsSettings();
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
// Mock the useSettings hook
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("#/hooks/query/use-settings")
|
||||
>("#/hooks/query/use-settings");
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
|
||||
},
|
||||
data: { EMAIL_VERIFIED: true },
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
@@ -52,21 +52,36 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
// Mock useConfig hook
|
||||
const { mockUseConfig } = vi.hoisted(() => ({
|
||||
const { mockUseConfig, mockUseMe, mockUsePermission } = vi.hoisted(() => ({
|
||||
mockUseConfig: vi.fn(),
|
||||
mockUseMe: vi.fn(),
|
||||
mockUsePermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: mockUseConfig,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-me", () => ({
|
||||
useMe: mockUseMe,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/organizations/use-permissions", () => ({
|
||||
usePermission: () => ({
|
||||
hasPermission: mockUsePermission,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
beforeEach(() => {
|
||||
// Set default config to OSS mode
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
// Set default config to OSS mode with lowercase keys
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "oss",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
@@ -80,6 +95,13 @@ describe("Settings Billing", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(false); // default: no billing access
|
||||
});
|
||||
|
||||
const RoutesStub = createRoutesStub([
|
||||
@@ -104,14 +126,38 @@ describe("Settings Billing", () => {
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () =>
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
|
||||
render(<RoutesStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("should not render the billing tab if OSS mode", async () => {
|
||||
// OSS mode is set by default in beforeEach
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "oss",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -119,12 +165,10 @@ describe("Settings Billing", () => {
|
||||
expect(credits).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing tab if SaaS mode and billing is enabled", async () => {
|
||||
it("should render the billing tab if: SaaS mode, billing enabled, admin user", async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "saas",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
@@ -139,19 +183,23 @@ describe("Settings Billing", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
within(navbar).getByText("Billing");
|
||||
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the billing item", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("should NOT render the billing tab if: SaaS mode, billing is enabled, and member user", async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "saas",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
@@ -166,6 +214,43 @@ describe("Settings Billing", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "member" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(false);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the billing item", async () => {
|
||||
const user = userEvent.setup();
|
||||
// When enable_billing is true, the billing nav item is shown
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, {
|
||||
clientLoader,
|
||||
getFirstAvailablePath,
|
||||
} from "#/routes/settings";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import { getFirstAvailablePath } from "#/utils/settings-utils";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
// Module-level mocks using vi.hoisted
|
||||
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
|
||||
@@ -57,17 +60,44 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
// @ts-expect-error - custom loader
|
||||
clientLoader,
|
||||
loader: clientLoader,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: () => <div data-testid="llm-settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/integrations",
|
||||
@@ -84,6 +114,15 @@ describe("Settings Screen", () => {
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
path: "/settings/api-keys",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="org-members-settings-screen" />,
|
||||
path: "/settings/org-members",
|
||||
handle: { hideTitle: true },
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="organization-settings-screen" />,
|
||||
path: "/settings/org",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -129,11 +168,21 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
const saasConfig = { app_mode: "saas" };
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Clear any existing query data and set the config
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
const sectionsToInclude = [
|
||||
"llm", // LLM settings are now always shown in SaaS mode
|
||||
@@ -149,6 +198,9 @@ describe("Settings Screen", () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
await waitFor(() => {
|
||||
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
|
||||
});
|
||||
sectionsToInclude.forEach((section) => {
|
||||
const sectionElement = within(navbar).getByText(section, {
|
||||
exact: false, // case insensitive
|
||||
@@ -200,12 +252,367 @@ describe("Settings Screen", () => {
|
||||
|
||||
it.todo("should not be able to access oss-only routes in saas mode");
|
||||
|
||||
describe("Personal org vs team org visibility", () => {
|
||||
it("should not show Organization and Organization Members settings items when personal org is selected", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Organization and Organization Members should NOT be visible for personal org
|
||||
expect(
|
||||
within(navbar).queryByText("Organization Members"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(navbar).queryByText("Organization"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Billing settings item when team org is selected", async () => {
|
||||
// Set up SaaS mode (which has Billing in nav items)
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
// Pre-select the team org in the query client and Zustand store
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "2" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "2",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Wait for orgs to load, then verify Billing is hidden for team orgs
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(navbar).queryByText("Billing", { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow direct URL access to /settings/org when personal org is selected", async () => {
|
||||
// Set up orgs in query client so clientLoader can access them
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
// Use Zustand store instead of query client for selected org ID
|
||||
// This is the correct pattern - the query client key ["selected_organization"] is never set in production
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen("/settings/org");
|
||||
|
||||
// Should redirect away from org settings for personal org
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("organization-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow direct URL access to /settings/org-members when personal org is selected", async () => {
|
||||
// Set up config and organizations in query client so clientLoader can access them
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
// Use Zustand store for selected org ID
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
// Mock getMe so getActiveOrganizationUser returns admin
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: "1" }),
|
||||
);
|
||||
|
||||
// Act: Call clientLoader directly with the REAL route path (as defined in routes.ts)
|
||||
const request = new Request("http://localhost/settings/org-members");
|
||||
// @ts-expect-error - test only needs request and params, not full loader args
|
||||
const result = await clientLoader({ request, params: {} });
|
||||
|
||||
// Assert: Should redirect away from org-members settings for personal org
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/settings");
|
||||
});
|
||||
|
||||
it("should not allow direct URL access to /settings/billing when team org is selected", async () => {
|
||||
// Set up orgs in query client so clientLoader can access them
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
// Use Zustand store instead of query client for selected org ID
|
||||
useSelectedOrganizationStore.setState({ organizationId: "2" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen("/settings/billing");
|
||||
|
||||
// Should redirect away from billing settings for team org
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("billing-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable_billing feature flag", () => {
|
||||
it("should show billing navigation item when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true, // When enable_billing is true, billing nav is shown
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryClient.clear();
|
||||
// Set up personal org (billing is only shown for personal orgs, not team orgs)
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderSettingsScreen();
|
||||
|
||||
// Assert
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
await waitFor(() => {
|
||||
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should hide billing navigation item when enable_billing is false", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false, // When enable_billing is false, billing nav is hidden
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryClient.clear();
|
||||
|
||||
// Act
|
||||
renderSettingsScreen();
|
||||
|
||||
// Assert
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader reads org ID from Zustand store", () => {
|
||||
beforeEach(() => {
|
||||
mockQueryClient.clear();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should redirect away from /settings/org when personal org is selected in Zustand store", async () => {
|
||||
// Arrange: Set up config and organizations in query client
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
// Set org ID ONLY in Zustand store (not in query client)
|
||||
// This tests that clientLoader reads from the correct source
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
// Mock getMe so getActiveOrganizationUser returns admin
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: "1" }),
|
||||
);
|
||||
|
||||
// Act: Call clientLoader directly
|
||||
const request = new Request("http://localhost/settings/org");
|
||||
// @ts-expect-error - test only needs request and params, not full loader args
|
||||
const result = await clientLoader({ request, params: {} });
|
||||
|
||||
// Assert: Should redirect away from org settings for personal org
|
||||
expect(result).not.toBeNull();
|
||||
// In React Router, redirect returns a Response object
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/settings");
|
||||
});
|
||||
|
||||
it("should redirect away from /settings/billing when team org is selected in Zustand store", async () => {
|
||||
// Arrange: Set up config and organizations in query client
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
// Set org ID ONLY in Zustand store (not in query client)
|
||||
useSelectedOrganizationStore.setState({ organizationId: "2" });
|
||||
|
||||
// Mock getMe so getActiveOrganizationUser returns admin
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: "2" }),
|
||||
);
|
||||
|
||||
// Act: Call clientLoader directly
|
||||
const request = new Request("http://localhost/settings/billing");
|
||||
// @ts-expect-error - test only needs request and params, not full loader args
|
||||
const result = await clientLoader({ request, params: {} });
|
||||
|
||||
// Assert: Should redirect away from billing settings for team org
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/settings/user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hide page feature flags", () => {
|
||||
beforeEach(() => {
|
||||
// Set up as personal org admin so billing is accessible
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide users page in navbar when hide_users_page is true", async () => {
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true, // Enable billing so it's not hidden by isBillingHidden
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -218,6 +625,14 @@ describe("Settings Screen", () => {
|
||||
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
// Set up personal org so billing is visible
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
// Pre-populate user data in cache so useMe() returns admin role immediately
|
||||
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -238,7 +653,7 @@ describe("Settings Screen", () => {
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -251,6 +666,11 @@ describe("Settings Screen", () => {
|
||||
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -271,7 +691,7 @@ describe("Settings Screen", () => {
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -284,6 +704,13 @@ describe("Settings Screen", () => {
|
||||
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
// Pre-populate user data in cache so useMe() returns admin role immediately
|
||||
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
describe("useSelectedOrganizationStore", () => {
|
||||
it("should have null as initial organizationId", () => {
|
||||
const { result } = renderHook(() => useSelectedOrganizationStore());
|
||||
expect(result.current.organizationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should update organizationId when setOrganizationId is called", () => {
|
||||
const { result } = renderHook(() => useSelectedOrganizationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setOrganizationId("org-123");
|
||||
});
|
||||
|
||||
expect(result.current.organizationId).toBe("org-123");
|
||||
});
|
||||
|
||||
it("should allow setting organizationId to null", () => {
|
||||
const { result } = renderHook(() => useSelectedOrganizationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setOrganizationId("org-123");
|
||||
});
|
||||
|
||||
expect(result.current.organizationId).toBe("org-123");
|
||||
|
||||
act(() => {
|
||||
result.current.setOrganizationId(null);
|
||||
});
|
||||
|
||||
expect(result.current.organizationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should share state across multiple hook instances", () => {
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useSelectedOrganizationStore(),
|
||||
);
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useSelectedOrganizationStore(),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result1.current.setOrganizationId("shared-organization");
|
||||
});
|
||||
|
||||
expect(result2.current.organizationId).toBe("shared-organization");
|
||||
});
|
||||
});
|
||||
50
frontend/__tests__/utils/billing-visibility.test.ts
Normal file
50
frontend/__tests__/utils/billing-visibility.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isBillingHidden } from "#/utils/org/billing-visibility";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
describe("isBillingHidden", () => {
|
||||
const createConfig = (
|
||||
featureFlagOverrides: Partial<WebClientConfig["feature_flags"]> = {},
|
||||
): WebClientConfig =>
|
||||
({
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
...featureFlagOverrides,
|
||||
},
|
||||
}) as WebClientConfig;
|
||||
|
||||
it("should return true when config is undefined (safe default)", () => {
|
||||
expect(isBillingHidden(undefined, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when enable_billing is false", () => {
|
||||
const config = createConfig({ enable_billing: false });
|
||||
expect(isBillingHidden(config, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user lacks view_billing permission", () => {
|
||||
const config = createConfig();
|
||||
expect(isBillingHidden(config, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when both enable_billing is false and user lacks permission", () => {
|
||||
const config = createConfig({ enable_billing: false });
|
||||
expect(isBillingHidden(config, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when enable_billing is true and user has view_billing permission", () => {
|
||||
const config = createConfig();
|
||||
expect(isBillingHidden(config, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("should treat enable_billing as true by default (billing visible, subject to permission)", () => {
|
||||
const config = createConfig({ enable_billing: true });
|
||||
expect(isBillingHidden(config, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
172
frontend/__tests__/utils/input-validation.test.ts
Normal file
172
frontend/__tests__/utils/input-validation.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isValidEmail,
|
||||
getInvalidEmails,
|
||||
areAllEmailsValid,
|
||||
hasDuplicates,
|
||||
} from "#/utils/input-validation";
|
||||
|
||||
describe("isValidEmail", () => {
|
||||
describe("valid email formats", () => {
|
||||
test("accepts standard email formats", () => {
|
||||
expect(isValidEmail("user@example.com")).toBe(true);
|
||||
expect(isValidEmail("john.doe@company.org")).toBe(true);
|
||||
expect(isValidEmail("test@subdomain.domain.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts emails with numbers", () => {
|
||||
expect(isValidEmail("user123@example.com")).toBe(true);
|
||||
expect(isValidEmail("123user@example.com")).toBe(true);
|
||||
expect(isValidEmail("user@example123.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts emails with special characters in local part", () => {
|
||||
expect(isValidEmail("user.name@example.com")).toBe(true);
|
||||
expect(isValidEmail("user+tag@example.com")).toBe(true);
|
||||
expect(isValidEmail("user_name@example.com")).toBe(true);
|
||||
expect(isValidEmail("user-name@example.com")).toBe(true);
|
||||
expect(isValidEmail("user%tag@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts emails with various TLDs", () => {
|
||||
expect(isValidEmail("user@example.io")).toBe(true);
|
||||
expect(isValidEmail("user@example.co.uk")).toBe(true);
|
||||
expect(isValidEmail("user@example.travel")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid email formats", () => {
|
||||
test("rejects empty strings", () => {
|
||||
expect(isValidEmail("")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without @", () => {
|
||||
expect(isValidEmail("userexample.com")).toBe(false);
|
||||
expect(isValidEmail("user.example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without domain", () => {
|
||||
expect(isValidEmail("user@")).toBe(false);
|
||||
expect(isValidEmail("user@.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without local part", () => {
|
||||
expect(isValidEmail("@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without TLD", () => {
|
||||
expect(isValidEmail("user@example")).toBe(false);
|
||||
expect(isValidEmail("user@example.")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings with single character TLD", () => {
|
||||
expect(isValidEmail("user@example.c")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects plain text", () => {
|
||||
expect(isValidEmail("test")).toBe(false);
|
||||
expect(isValidEmail("just some text")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects emails with spaces", () => {
|
||||
expect(isValidEmail("user @example.com")).toBe(false);
|
||||
expect(isValidEmail("user@ example.com")).toBe(false);
|
||||
expect(isValidEmail(" user@example.com")).toBe(false);
|
||||
expect(isValidEmail("user@example.com ")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects emails with multiple @ symbols", () => {
|
||||
expect(isValidEmail("user@@example.com")).toBe(false);
|
||||
expect(isValidEmail("user@domain@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvalidEmails", () => {
|
||||
test("returns empty array when all emails are valid", () => {
|
||||
const emails = ["user@example.com", "test@domain.org"];
|
||||
expect(getInvalidEmails(emails)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all invalid emails", () => {
|
||||
const emails = ["valid@example.com", "invalid", "test@", "another@valid.org"];
|
||||
expect(getInvalidEmails(emails)).toEqual(["invalid", "test@"]);
|
||||
});
|
||||
|
||||
test("returns all emails when none are valid", () => {
|
||||
const emails = ["invalid", "also-invalid", "no-at-symbol"];
|
||||
expect(getInvalidEmails(emails)).toEqual(emails);
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
expect(getInvalidEmails([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles array with single invalid email", () => {
|
||||
expect(getInvalidEmails(["invalid"])).toEqual(["invalid"]);
|
||||
});
|
||||
|
||||
test("handles array with single valid email", () => {
|
||||
expect(getInvalidEmails(["valid@example.com"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("areAllEmailsValid", () => {
|
||||
test("returns true when all emails are valid", () => {
|
||||
const emails = ["user@example.com", "test@domain.org", "admin@company.io"];
|
||||
expect(areAllEmailsValid(emails)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when any email is invalid", () => {
|
||||
const emails = ["user@example.com", "invalid", "test@domain.org"];
|
||||
expect(areAllEmailsValid(emails)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when all emails are invalid", () => {
|
||||
const emails = ["invalid", "also-invalid"];
|
||||
expect(areAllEmailsValid(emails)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for empty array", () => {
|
||||
expect(areAllEmailsValid([])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for single valid email", () => {
|
||||
expect(areAllEmailsValid(["valid@example.com"])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for single invalid email", () => {
|
||||
expect(areAllEmailsValid(["invalid"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDuplicates", () => {
|
||||
test("returns false when all values are unique", () => {
|
||||
expect(hasDuplicates(["a@test.com", "b@test.com", "c@test.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns true when duplicates exist", () => {
|
||||
expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for case-insensitive duplicates", () => {
|
||||
expect(hasDuplicates(["User@Test.com", "user@test.com"])).toBe(true);
|
||||
expect(hasDuplicates(["A@EXAMPLE.COM", "a@example.com"])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty array", () => {
|
||||
expect(hasDuplicates([])).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for single item array", () => {
|
||||
expect(hasDuplicates(["single@test.com"])).toBe(false);
|
||||
});
|
||||
|
||||
test("handles multiple duplicates", () => {
|
||||
expect(
|
||||
hasDuplicates(["a@test.com", "a@test.com", "b@test.com", "b@test.com"]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
79
frontend/__tests__/utils/permission-checks.test.ts
Normal file
79
frontend/__tests__/utils/permission-checks.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { PermissionKey } from "#/utils/org/permissions";
|
||||
|
||||
// Mock dependencies for getActiveOrganizationUser tests
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
getMe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/stores/selected-organization-store", () => ({
|
||||
getSelectedOrganizationIdFromStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/query-client-getters", () => ({
|
||||
getMeFromQueryClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: {
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import {
|
||||
getAvailableRolesAUserCanAssign,
|
||||
getActiveOrganizationUser,
|
||||
} from "#/utils/org/permission-checks";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
|
||||
import { getMeFromQueryClient } from "#/utils/query-client-getters";
|
||||
|
||||
describe("getAvailableRolesAUserCanAssign", () => {
|
||||
it("returns empty array if user has no permissions", () => {
|
||||
const result = getAvailableRolesAUserCanAssign([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only roles the user has permission for", () => {
|
||||
const userPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(userPermissions);
|
||||
expect(result.sort()).toEqual(["admin", "member"].sort());
|
||||
});
|
||||
|
||||
it("returns all roles if user has all permissions", () => {
|
||||
const allPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
"change_user_role:owner",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(allPermissions);
|
||||
expect(result.sort()).toEqual(["member", "admin", "owner"].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveOrganizationUser", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return undefined when API call throws an error", async () => {
|
||||
// Arrange: orgId exists, cache is empty, API call fails
|
||||
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
|
||||
vi.mocked(getMeFromQueryClient).mockReturnValue(undefined);
|
||||
vi.mocked(organizationService.getMe).mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert: should return undefined instead of propagating the error
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
175
frontend/__tests__/utils/permission-guard.test.ts
Normal file
175
frontend/__tests__/utils/permission-guard.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
vi.mock("react-router", () => ({
|
||||
redirect: vi.fn((path: string) => ({ type: "redirect", path })),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/org/permission-checks", () => ({
|
||||
getActiveOrganizationUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/api/option-service/option-service.api", () => ({
|
||||
default: {
|
||||
getConfig: vi.fn().mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
hide_llm_settings: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
hide_llm_settings: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: {
|
||||
getQueryData: vi.fn(() => mockConfig),
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
import { getActiveOrganizationUser } from "#/utils/org/permission-checks";
|
||||
|
||||
// Helper to create a mock request
|
||||
const createMockRequest = (pathname: string = "/settings/billing") => ({
|
||||
request: new Request(`http://localhost${pathname}`),
|
||||
});
|
||||
|
||||
describe("createPermissionGuard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("permission checking", () => {
|
||||
it("should redirect when user lacks required permission", async () => {
|
||||
// Arrange: member lacks view_billing permission
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should redirect to first available path (/settings/user in SaaS mode)
|
||||
expect(redirect).toHaveBeenCalledWith("/settings/user");
|
||||
});
|
||||
|
||||
it("should allow access when user has required permission", async () => {
|
||||
// Arrange: admin has view_billing permission
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
const result = await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should not redirect, return null
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should redirect when user is undefined (no org selected)", async () => {
|
||||
// Arrange: no user (e.g., no organization selected)
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should redirect to first available path
|
||||
expect(redirect).toHaveBeenCalledWith("/settings/user");
|
||||
});
|
||||
|
||||
it("should redirect when user is undefined even for member-level permissions", async () => {
|
||||
// Arrange: no user — manage_secrets is a member-level permission,
|
||||
// but undefined user should NOT get member access
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("manage_secrets");
|
||||
await guard(createMockRequest("/settings/secrets"));
|
||||
|
||||
// Assert: should redirect, not silently grant member-level access
|
||||
expect(redirect).toHaveBeenCalledWith("/settings/user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom redirect path", () => {
|
||||
it("should redirect to custom path when specified", async () => {
|
||||
// Arrange: member lacks permission
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing", "/custom/redirect");
|
||||
await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should redirect to custom path
|
||||
expect(redirect).toHaveBeenCalledWith("/custom/redirect");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infinite loop prevention", () => {
|
||||
it("should return null instead of redirecting when fallback path equals current path", async () => {
|
||||
// Arrange: no user
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined);
|
||||
|
||||
// Act: access /settings/user when fallback would also be /settings/user
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
const result = await guard(createMockRequest("/settings/user"));
|
||||
|
||||
// Assert: should NOT redirect to avoid infinite loop
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run dev:mock -- --port 3001",
|
||||
command: "npm run dev:mock:saas -- --port 3001",
|
||||
url: "http://localhost:3001/",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
||||
@@ -253,7 +253,7 @@ class V1ConversationService {
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload/{path}
|
||||
* V1 API endpoint: POST /api/file/upload?path={path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
@@ -269,10 +269,11 @@ class V1ConversationService {
|
||||
): Promise<void> {
|
||||
// Default to /workspace/{filename} if no path provided (must be absolute)
|
||||
const uploadPath = path || `/workspace/${file.name}`;
|
||||
const encodedPath = encodeURIComponent(uploadPath);
|
||||
const params = new URLSearchParams();
|
||||
params.append("path", uploadPath);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
`/api/file/upload?${params.toString()}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
OrganizationMembersPage,
|
||||
UpdateOrganizationMemberParams,
|
||||
} from "#/types/org";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
export const organizationService = {
|
||||
getMe: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<OrganizationMember>(
|
||||
`/api/organizations/${orgId}/me`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<Organization>(
|
||||
`/api/organizations/${orgId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganizations: async () => {
|
||||
const { data } = await openHands.get<{
|
||||
items: Organization[];
|
||||
current_org_id: string | null;
|
||||
}>("/api/organizations");
|
||||
return {
|
||||
items: data?.items || [],
|
||||
currentOrgId: data?.current_org_id || null,
|
||||
};
|
||||
},
|
||||
|
||||
updateOrganization: async ({
|
||||
orgId,
|
||||
name,
|
||||
}: {
|
||||
orgId: string;
|
||||
name: string;
|
||||
}) => {
|
||||
const { data } = await openHands.patch<Organization>(
|
||||
`/api/organizations/${orgId}`,
|
||||
{ name },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
await openHands.delete(`/api/organizations/${orgId}`);
|
||||
},
|
||||
|
||||
getOrganizationMembers: async ({
|
||||
orgId,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
email,
|
||||
}: {
|
||||
orgId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
email?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Calculate offset from page number (page_id is offset-based)
|
||||
const offset = (page - 1) * limit;
|
||||
params.set("page_id", String(offset));
|
||||
params.set("limit", String(limit));
|
||||
|
||||
if (email) {
|
||||
params.set("email", email);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<OrganizationMembersPage>(
|
||||
`/api/organizations/${orgId}/members?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganizationMembersCount: async ({
|
||||
orgId,
|
||||
email,
|
||||
}: {
|
||||
orgId: string;
|
||||
email?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (email) {
|
||||
params.set("email", email);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/organizations/${orgId}/members/count?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganizationPaymentInfo: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<{
|
||||
cardNumber: string;
|
||||
}>(`/api/organizations/${orgId}/payment`);
|
||||
return data;
|
||||
},
|
||||
|
||||
updateMember: async ({
|
||||
orgId,
|
||||
userId,
|
||||
...updateData
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
} & UpdateOrganizationMemberParams) => {
|
||||
const { data } = await openHands.patch(
|
||||
`/api/organizations/${orgId}/members/${userId}`,
|
||||
updateData,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
removeMember: async ({
|
||||
orgId,
|
||||
userId,
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
await openHands.delete(`/api/organizations/${orgId}/members/${userId}`);
|
||||
},
|
||||
|
||||
inviteMembers: async ({
|
||||
orgId,
|
||||
emails,
|
||||
}: {
|
||||
orgId: string;
|
||||
emails: string[];
|
||||
}) => {
|
||||
const { data } = await openHands.post<OrganizationMember[]>(
|
||||
`/api/organizations/${orgId}/members/invite`,
|
||||
{
|
||||
emails,
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
switchOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.post<Organization>(
|
||||
`/api/organizations/${orgId}/switch`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaUserShield } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
@@ -65,6 +66,12 @@ export function LoginContent({
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const enterpriseSsoAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "enterprise_sso",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const handleAuthRedirect = async (
|
||||
redirectUrl: string,
|
||||
provider: Provider,
|
||||
@@ -127,6 +134,12 @@ export function LoginContent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
if (enterpriseSsoAuthUrl) {
|
||||
handleAuthRedirect(enterpriseSsoAuthUrl, "enterprise_sso");
|
||||
}
|
||||
};
|
||||
|
||||
const showGithub =
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
@@ -143,6 +156,10 @@ export function LoginContent({
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
providersConfigured.includes("bitbucket_data_center");
|
||||
const showEnterpriseSso =
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
providersConfigured.includes("enterprise_sso");
|
||||
|
||||
const noProvidersConfigured =
|
||||
!providersConfigured || providersConfigured.length === 0;
|
||||
@@ -261,6 +278,19 @@ export function LoginContent({
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showEnterpriseSso && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnterpriseSsoAuth}
|
||||
className={`${buttonBaseClasses} bg-[#374151] text-white`}
|
||||
>
|
||||
<FaUserShield size={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "./context-menu-list-item";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import LogOutIcon from "#/icons/log-out.svg?react";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface AccountSettingsContextMenuProps {
|
||||
onLogout: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountSettingsContextMenu({
|
||||
onLogout,
|
||||
onClose,
|
||||
}: AccountSettingsContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
const { trackAddTeamMembersButtonClick } = useTracking();
|
||||
const { data: config } = useConfig();
|
||||
const { data: settings } = useSettings();
|
||||
const isAddTeamMemberEnabled = useFeatureFlagEnabled(
|
||||
"exp_add_team_member_button",
|
||||
);
|
||||
// Get navigation items and filter out LLM settings if the feature flag is enabled
|
||||
const items = useSettingsNavItems();
|
||||
|
||||
const isSaasMode = config?.app_mode === "saas";
|
||||
const hasAnalyticsConsent = settings?.user_consents_to_analytics === true;
|
||||
const showAddTeamMembers =
|
||||
isSaasMode && isAddTeamMemberEnabled && hasAnalyticsConsent;
|
||||
|
||||
const navItems = items.map((item) => ({
|
||||
...item,
|
||||
icon: React.cloneElement(item.icon, {
|
||||
width: 16,
|
||||
height: 16,
|
||||
} as React.SVGProps<SVGSVGElement>),
|
||||
}));
|
||||
const handleNavigationClick = () => onClose();
|
||||
|
||||
const handleAddTeamMembers = () => {
|
||||
trackAddTeamMembersButtonClick();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
testId="account-settings-context-menu"
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
className="mt-0 md:right-full md:left-full md:bottom-0 ml-0 w-fit z-[9999]"
|
||||
>
|
||||
{showAddTeamMembers && (
|
||||
<ContextMenuListItem
|
||||
testId="add-team-members-button"
|
||||
onClick={handleAddTeamMembers}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<PlusIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">
|
||||
{t(I18nKey.SETTINGS$NAV_ADD_TEAM_MEMBERS)}
|
||||
</span>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{navItems.map(({ to, text, icon }) => (
|
||||
<Link key={to} to={to} className="text-decoration-none">
|
||||
<ContextMenuListItem
|
||||
onClick={handleNavigationClick}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-white text-sm">{t(text)}</span>
|
||||
</ContextMenuListItem>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-none"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<DocumentIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">{t(I18nKey.SIDEBAR$DOCS)}</span>
|
||||
</ContextMenuListItem>
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<LogOutIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</span>
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OrgModal } from "#/components/shared/modals/org-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUpdateOrganization } from "#/hooks/mutation/use-update-organization";
|
||||
|
||||
interface ChangeOrgNameModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangeOrgNameModal({ onClose }: ChangeOrgNameModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: updateOrganization, isPending } = useUpdateOrganization();
|
||||
const [orgName, setOrgName] = useState<string>("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (orgName?.trim()) {
|
||||
updateOrganization(orgName, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
testId="update-org-name-form"
|
||||
title={t(I18nKey.ORG$CHANGE_ORG_NAME)}
|
||||
description={t(I18nKey.ORG$MODIFY_ORG_NAME_DESCRIPTION)}
|
||||
primaryButtonText={t(I18nKey.BUTTON$SAVE)}
|
||||
onPrimaryClick={handleSubmit}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<input
|
||||
data-testid="org-name"
|
||||
value={orgName}
|
||||
placeholder={t(I18nKey.ORG$ENTER_NEW_ORGANIZATION_NAME)}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
className="bg-tertiary border border-[#717888] h-10 w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt"
|
||||
/>
|
||||
</OrgModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { OrgModal } from "#/components/shared/modals/org-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConfirmRemoveMemberModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
memberEmail: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmRemoveMemberModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
memberEmail,
|
||||
isLoading = false,
|
||||
}: ConfirmRemoveMemberModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const confirmationMessage = (
|
||||
<Trans
|
||||
i18nKey={I18nKey.ORG$REMOVE_MEMBER_WARNING}
|
||||
values={{ email: memberEmail }}
|
||||
components={{ email: <span className="text-white" /> }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
title={t(I18nKey.ORG$CONFIRM_REMOVE_MEMBER)}
|
||||
description={confirmationMessage}
|
||||
primaryButtonText={t(I18nKey.BUTTON$CONFIRM)}
|
||||
secondaryButtonText={t(I18nKey.BUTTON$CANCEL)}
|
||||
onPrimaryClick={onConfirm}
|
||||
onClose={onCancel}
|
||||
isLoading={isLoading}
|
||||
primaryButtonTestId="confirm-button"
|
||||
secondaryButtonTestId="cancel-button"
|
||||
fullWidthButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { OrgModal } from "#/components/shared/modals/org-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
|
||||
interface ConfirmUpdateRoleModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
memberEmail: string;
|
||||
newRole: OrganizationUserRole;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmUpdateRoleModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
memberEmail,
|
||||
newRole,
|
||||
isLoading = false,
|
||||
}: ConfirmUpdateRoleModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const confirmationMessage = (
|
||||
<Trans
|
||||
i18nKey={I18nKey.ORG$UPDATE_ROLE_WARNING}
|
||||
values={{ email: memberEmail, role: newRole }}
|
||||
components={{
|
||||
email: <span className="text-white" />,
|
||||
role: <span className="text-white capitalize" />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
title={t(I18nKey.ORG$CONFIRM_UPDATE_ROLE)}
|
||||
description={confirmationMessage}
|
||||
primaryButtonText={t(I18nKey.BUTTON$CONFIRM)}
|
||||
onPrimaryClick={onConfirm}
|
||||
onClose={onCancel}
|
||||
isLoading={isLoading}
|
||||
primaryButtonTestId="confirm-button"
|
||||
secondaryButtonTestId="cancel-button"
|
||||
fullWidthButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { OrgModal } from "#/components/shared/modals/org-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useDeleteOrganization } from "#/hooks/mutation/use-delete-organization";
|
||||
import { useOrganization } from "#/hooks/query/use-organization";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
interface DeleteOrgConfirmationModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeleteOrgConfirmationModal({
|
||||
onClose,
|
||||
}: DeleteOrgConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: deleteOrganization, isPending } = useDeleteOrganization();
|
||||
const { data: organization } = useOrganization();
|
||||
|
||||
const handleConfirm = () => {
|
||||
deleteOrganization(undefined, {
|
||||
onSuccess: onClose,
|
||||
onError: () => {
|
||||
displayErrorToast(t(I18nKey.ORG$DELETE_ORGANIZATION_ERROR));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmationMessage = organization?.name ? (
|
||||
<Trans
|
||||
i18nKey={I18nKey.ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME}
|
||||
values={{ name: organization.name }}
|
||||
components={{ name: <span className="text-white" /> }}
|
||||
/>
|
||||
) : (
|
||||
t(I18nKey.ORG$DELETE_ORGANIZATION_WARNING)
|
||||
);
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
testId="delete-org-confirmation"
|
||||
title={t(I18nKey.ORG$DELETE_ORGANIZATION)}
|
||||
description={confirmationMessage}
|
||||
primaryButtonText={t(I18nKey.BUTTON$CONFIRM)}
|
||||
onPrimaryClick={handleConfirm}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
secondaryButtonTestId="cancel-button"
|
||||
ariaLabel={t(I18nKey.ORG$DELETE_ORGANIZATION)}
|
||||
fullWidthButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OrgModal } from "#/components/shared/modals/org-modal";
|
||||
import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { areAllEmailsValid, hasDuplicates } from "#/utils/input-validation";
|
||||
|
||||
interface InviteOrganizationMemberModalProps {
|
||||
onClose: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function InviteOrganizationMemberModal({
|
||||
onClose,
|
||||
}: InviteOrganizationMemberModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: inviteMembers, isPending } = useInviteMembersBatch();
|
||||
const [emails, setEmails] = React.useState<string[]>([]);
|
||||
|
||||
const handleEmailsChange = (newEmails: string[]) => {
|
||||
const trimmedEmails = newEmails.map((email) => email.trim());
|
||||
setEmails(trimmedEmails);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (emails.length === 0) {
|
||||
displayErrorToast(t(I18nKey.ORG$NO_EMAILS_ADDED_HINT));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!areAllEmailsValid(emails)) {
|
||||
displayErrorToast(t(I18nKey.SETTINGS$INVALID_EMAIL_FORMAT));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasDuplicates(emails)) {
|
||||
displayErrorToast(t(I18nKey.ORG$DUPLICATE_EMAILS_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
inviteMembers(
|
||||
{ emails },
|
||||
{
|
||||
onSuccess: () => onClose(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<OrgModal
|
||||
testId="invite-modal"
|
||||
title={t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
description={t(I18nKey.ORG$INVITE_USERS_DESCRIPTION)}
|
||||
primaryButtonText={t(I18nKey.BUTTON$ADD)}
|
||||
onPrimaryClick={handleSubmit}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<BadgeInput
|
||||
name="emails-badge-input"
|
||||
value={emails}
|
||||
placeholder={t(I18nKey.COMMON$TYPE_EMAIL_AND_PRESS_SPACE)}
|
||||
onChange={handleEmailsChange}
|
||||
/>
|
||||
</OrgModal>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/features/org/org-selector.tsx
Normal file
61
frontend/src/components/features/org/org-selector.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { useSwitchOrganization } from "#/hooks/mutation/use-switch-organization";
|
||||
import { useOrganizations } from "#/hooks/query/use-organizations";
|
||||
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Organization } from "#/types/org";
|
||||
import { Dropdown } from "#/ui/dropdown/dropdown";
|
||||
|
||||
export function OrgSelector() {
|
||||
const { t } = useTranslation();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
const { data, isLoading } = useOrganizations();
|
||||
const organizations = data?.organizations;
|
||||
const { mutate: switchOrganization, isPending: isSwitching } =
|
||||
useSwitchOrganization();
|
||||
const shouldHideSelector = useShouldHideOrgSelector();
|
||||
|
||||
const getOrgDisplayName = React.useCallback(
|
||||
(org: Organization) =>
|
||||
org.is_personal ? t(I18nKey.ORG$PERSONAL_WORKSPACE) : org.name,
|
||||
[t],
|
||||
);
|
||||
|
||||
const selectedOrg = React.useMemo(() => {
|
||||
if (organizationId) {
|
||||
return organizations?.find((org) => org.id === organizationId);
|
||||
}
|
||||
|
||||
return organizations?.[0];
|
||||
}, [organizationId, organizations]);
|
||||
|
||||
if (shouldHideSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
testId="org-selector"
|
||||
key={`${selectedOrg?.id}-${selectedOrg?.name}`}
|
||||
defaultValue={{
|
||||
label: selectedOrg ? getOrgDisplayName(selectedOrg) : "",
|
||||
value: selectedOrg?.id || "",
|
||||
}}
|
||||
onChange={(item) => {
|
||||
if (item && item.value !== organizationId) {
|
||||
switchOrganization(item.value);
|
||||
}
|
||||
}}
|
||||
placeholder={t(I18nKey.ORG$SELECT_ORGANIZATION_PLACEHOLDER)}
|
||||
loading={isLoading || isSwitching}
|
||||
options={
|
||||
organizations?.map((org) => ({
|
||||
value: org.id,
|
||||
label: getOrgDisplayName(org),
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { OrganizationMember, OrganizationUserRole } from "#/types/org";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { OrganizationMemberRoleContextMenu } from "./organization-member-role-context-menu";
|
||||
|
||||
interface OrganizationMemberListItemProps {
|
||||
email: OrganizationMember["email"];
|
||||
role: OrganizationMember["role"];
|
||||
status: OrganizationMember["status"];
|
||||
hasPermissionToChangeRole: boolean;
|
||||
availableRolesToChangeTo: OrganizationUserRole[];
|
||||
|
||||
onRoleChange: (role: OrganizationUserRole) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function OrganizationMemberListItem({
|
||||
email,
|
||||
role,
|
||||
status,
|
||||
hasPermissionToChangeRole,
|
||||
availableRolesToChangeTo,
|
||||
onRoleChange,
|
||||
onRemove,
|
||||
}: OrganizationMemberListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
|
||||
|
||||
const roleSelectionIsPermitted =
|
||||
status !== "invited" && hasPermissionToChangeRole;
|
||||
|
||||
const handleRoleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
if (roleSelectionIsPermitted) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold leading-6",
|
||||
status === "invited" && "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
|
||||
{status === "invited" && (
|
||||
<span className="text-xs text-tertiary-light border border-tertiary px-2 py-1 rounded-lg">
|
||||
{t(I18nKey.ORG$STATUS_INVITED)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span
|
||||
onClick={handleRoleClick}
|
||||
className={cn(
|
||||
"text-xs font-normal leading-4 text-org-text flex items-center gap-1 capitalize",
|
||||
roleSelectionIsPermitted ? "cursor-pointer" : "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
{hasPermissionToChangeRole && <ChevronDown size={14} />}
|
||||
</span>
|
||||
|
||||
{roleSelectionIsPermitted && contextMenuOpen && (
|
||||
<OrganizationMemberRoleContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onRoleChange={onRoleChange}
|
||||
onRemove={onRemove}
|
||||
availableRolesToChangeTo={availableRolesToChangeTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { ContextMenuIconText } from "#/ui/context-menu-icon-text";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
import { cn } from "#/utils/utils";
|
||||
import UserIcon from "#/icons/user.svg?react";
|
||||
import DeleteIcon from "#/icons/u-delete.svg?react";
|
||||
import AdminIcon from "#/icons/admin.svg?react";
|
||||
|
||||
const contextMenuListItemClassName = cn(
|
||||
"cursor-pointer p-0 h-auto hover:bg-transparent",
|
||||
);
|
||||
|
||||
interface OrganizationMemberRoleContextMenuProps {
|
||||
onClose: () => void;
|
||||
onRoleChange: (role: OrganizationUserRole) => void;
|
||||
onRemove?: () => void;
|
||||
availableRolesToChangeTo: OrganizationUserRole[];
|
||||
}
|
||||
|
||||
export function OrganizationMemberRoleContextMenu({
|
||||
onClose,
|
||||
onRoleChange,
|
||||
onRemove,
|
||||
availableRolesToChangeTo,
|
||||
}: OrganizationMemberRoleContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const handleRoleChangeClick = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
role: OrganizationUserRole,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRoleChange(role);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemove?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
testId="organization-member-role-context-menu"
|
||||
position="bottom"
|
||||
alignment="right"
|
||||
className="min-h-fit mb-2 min-w-[195px] max-w-[195px] gap-0"
|
||||
>
|
||||
{availableRolesToChangeTo.includes("owner") && (
|
||||
<ContextMenuListItem
|
||||
testId="owner-option"
|
||||
onClick={(event) => handleRoleChangeClick(event, "owner")}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={
|
||||
<AdminIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-white pl-[2px]"
|
||||
/>
|
||||
}
|
||||
text={t(I18nKey.ORG$ROLE_OWNER)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{availableRolesToChangeTo.includes("admin") && (
|
||||
<ContextMenuListItem
|
||||
testId="admin-option"
|
||||
onClick={(event) => handleRoleChangeClick(event, "admin")}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={
|
||||
<AdminIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-white pl-[2px]"
|
||||
/>
|
||||
}
|
||||
text={t(I18nKey.ORG$ROLE_ADMIN)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{availableRolesToChangeTo.includes("member") && (
|
||||
<ContextMenuListItem
|
||||
testId="member-option"
|
||||
onClick={(event) => handleRoleChangeClick(event, "member")}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={<UserIcon width={16} height={16} className="text-white" />}
|
||||
text={t(I18nKey.ORG$ROLE_MEMBER)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
<ContextMenuListItem
|
||||
testId="remove-option"
|
||||
onClick={handleRemoveClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={<DeleteIcon width={16} height={16} className="text-red-500" />}
|
||||
text={t(I18nKey.ORG$REMOVE)}
|
||||
className="text-red-500 capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
|
||||
|
||||
export function PaymentForm() {
|
||||
export function PaymentForm({ isDisabled }: { isDisabled?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
@@ -69,13 +69,14 @@ export function PaymentForm() {
|
||||
min={10}
|
||||
max={25000}
|
||||
step={1}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
|
||||
<div className="flex items-center w-[680px] gap-2">
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={isPending || buttonIsDisabled}
|
||||
isDisabled={isPending || buttonIsDisabled || isDisabled}
|
||||
>
|
||||
{t(I18nKey.PAYMENT$ADD_CREDIT)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SetupPaymentModal() {
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
isDisabled={isPending}
|
||||
onClick={mutate}
|
||||
onClick={() => mutate()}
|
||||
>
|
||||
{t(I18nKey.BILLING$PROCEED_TO_STRIPE)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface BrandButtonProps {
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (event?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
startContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,10 @@ export function IntegrationRow({
|
||||
: t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between" data-testid={dataTestId}>
|
||||
<div
|
||||
className="flex items-center justify-between flex-wrap gap-2"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<span className="font-medium">{platformName}</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<ConfigureButton
|
||||
|
||||
@@ -63,7 +63,7 @@ export function SettingsDropdownInput({
|
||||
aria-label={typeof label === "string" ? label : name}
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
defaultItems={items}
|
||||
items={items}
|
||||
defaultSelectedKey={defaultSelectedKey}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={onSelectionChange}
|
||||
@@ -76,7 +76,7 @@ export function SettingsDropdownInput({
|
||||
isRequired={required}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
|
||||
popoverContent: "bg-tertiary rounded-xl",
|
||||
}}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import SettingsIcon from "#/icons/settings-gear.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { OrgSelector } from "../org/org-selector";
|
||||
import { SettingsNavItem } from "#/constants/settings-nav";
|
||||
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
isMobileMenuOpen: boolean;
|
||||
@@ -19,6 +21,7 @@ export function SettingsNavigation({
|
||||
navigationItems,
|
||||
}: SettingsNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldHideSelector = useShouldHideOrgSelector();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,13 +53,15 @@ export function SettingsNavigation({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseMobileMenu}
|
||||
className="md:hidden p-0.5 hover:bg-[#454545] rounded-md transition-colors cursor-pointer"
|
||||
className="md:hidden p-0.5 hover:bg-tertiary rounded-md transition-colors cursor-pointer"
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<CloseIcon width={32} height={32} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!shouldHideSelector && <OrgSelector />}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{navigationItems.map(({ to, icon, text }) => (
|
||||
<NavLink
|
||||
@@ -66,14 +71,21 @@ export function SettingsNavigation({
|
||||
onClick={onCloseMobileMenu}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 p-1 sm:px-[14px] sm:py-2 rounded-md transition-colors",
|
||||
isActive ? "bg-[#454545]" : "hover:bg-[#454545]",
|
||||
"group flex items-center gap-3 p-1 sm:px-3.5 sm:py-2 rounded-md transition-all duration-200",
|
||||
isActive ? "bg-tertiary" : "hover:bg-tertiary",
|
||||
)
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"block truncate whitespace-nowrap text-modal-muted transition-all duration-300",
|
||||
"group-hover:translate-x-1 group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{t(text as I18nKey)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { SettingsModal } from "#/components/shared/modals/settings/settings-moda
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -27,7 +26,6 @@ export function Sidebar() {
|
||||
isError: settingsIsError,
|
||||
isFetching: isFetchingSettings,
|
||||
} = useSettings();
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
|
||||
@@ -77,7 +75,7 @@ export function Sidebar() {
|
||||
<div className="flex items-center justify-center">
|
||||
<OpenHandsLogoButton />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center">
|
||||
<NewProjectButton disabled={settings?.email_verified === false} />
|
||||
</div>
|
||||
<ConversationPanelButton
|
||||
@@ -96,7 +94,6 @@ export function Sidebar() {
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
onLogout={logout}
|
||||
isLoading={user.isFetching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +1,88 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
|
||||
import { UserContextMenu } from "../user/user-context-menu";
|
||||
import { InviteOrganizationMemberModal } from "../org/invite-organization-member-modal";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
interface UserActionsProps {
|
||||
onLogout: () => void;
|
||||
user?: { avatar_url: string };
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
export function UserActions({ user, isLoading }: UserActionsProps) {
|
||||
const { data: me } = useMe();
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const { data: config } = useConfig();
|
||||
// Counter that increments each time the menu hides, used as a React key
|
||||
// to force UserContextMenu to remount with fresh state (resets dropdown
|
||||
// open/close, search text, and scroll position in the org selector).
|
||||
const [menuResetCount, setMenuResetCount] = React.useState(0);
|
||||
const [inviteMemberModalIsOpen, setInviteMemberModalIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
// Use the shared hook to determine if user actions should be shown
|
||||
const shouldShowUserActions = useShouldShowUserFeatures();
|
||||
|
||||
const toggleAccountMenu = () => {
|
||||
// Always toggle the menu, even if user is undefined
|
||||
setAccountContextMenuIsVisible((prev) => !prev);
|
||||
const showAccountMenu = () => {
|
||||
setAccountContextMenuIsVisible(true);
|
||||
};
|
||||
|
||||
const hideAccountMenu = () => {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
setMenuResetCount((c) => c + 1);
|
||||
};
|
||||
|
||||
const closeAccountMenu = () => {
|
||||
if (accountContextMenuIsVisible) {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
setMenuResetCount((c) => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
closeAccountMenu();
|
||||
const openInviteMemberModal = () => {
|
||||
setInviteMemberModalIsOpen(true);
|
||||
};
|
||||
|
||||
const isOSS = config?.app_mode === "oss";
|
||||
|
||||
// Show the menu based on the new logic
|
||||
const showMenu =
|
||||
accountContextMenuIsVisible && (shouldShowUserActions || isOSS);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="user-actions"
|
||||
className="w-8 h-8 relative cursor-pointer group"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={user?.avatar_url}
|
||||
onClick={toggleAccountMenu}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
data-testid="user-actions"
|
||||
className="relative cursor-pointer group"
|
||||
onMouseEnter={showAccountMenu}
|
||||
onMouseLeave={hideAccountMenu}
|
||||
>
|
||||
<UserAvatar avatarUrl={user?.avatar_url} isLoading={isLoading} />
|
||||
|
||||
{(shouldShowUserActions || isOSS) && (
|
||||
<div
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
showMenu && "opacity-100 pointer-events-auto",
|
||||
// Invisible hover bridge: extends hover zone to create a "safe corridor"
|
||||
// for diagonal mouse movement to the menu (only active when menu is visible)
|
||||
"group-hover:before:content-[''] group-hover:before:block group-hover:before:absolute group-hover:before:inset-[-320px] group-hover:before:z-9998",
|
||||
)}
|
||||
>
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowUserActions && user && (
|
||||
<div
|
||||
className={cn(
|
||||
"opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
|
||||
accountContextMenuIsVisible && "opacity-100 pointer-events-auto",
|
||||
// Invisible hover bridge: extends hover zone to create a "safe corridor"
|
||||
// for diagonal mouse movement to the menu (only active when menu is visible)
|
||||
"group-hover:before:content-[''] group-hover:before:block group-hover:before:absolute group-hover:before:inset-[-320px] group-hover:before:z-50 before:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<UserContextMenu
|
||||
key={menuResetCount}
|
||||
type={me?.role ?? "member"}
|
||||
onClose={closeAccountMenu}
|
||||
onOpenInviteModal={openInviteMemberModal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inviteMemberModalIsOpen &&
|
||||
ReactDOM.createPortal(
|
||||
<InviteOrganizationMemberModal
|
||||
onClose={() => setInviteMemberModalIsOpen(false)}
|
||||
/>,
|
||||
document.getElementById("portal-root") || document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import { cn } from "#/utils/utils";
|
||||
import { Avatar } from "./avatar";
|
||||
|
||||
interface UserAvatarProps {
|
||||
onClick: () => void;
|
||||
avatarUrl?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
export function UserAvatar({ avatarUrl, isLoading }: UserAvatarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -22,7 +21,6 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
"w-8 h-8 rounded-full flex items-center justify-center cursor-pointer",
|
||||
isLoading && "bg-transparent",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
|
||||
{!isLoading && !avatarUrl && (
|
||||
|
||||
168
frontend/src/components/features/user/user-context-menu.tsx
Normal file
168
frontend/src/components/features/user/user-context-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IoCardOutline,
|
||||
IoLogOutOutline,
|
||||
IoPersonAddOutline,
|
||||
} from "react-icons/io5";
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { OrgSelector } from "../org/org-selector";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
|
||||
|
||||
// Shared className for context menu list items in the user context menu
|
||||
const contextMenuListItemClassName = cn(
|
||||
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded",
|
||||
);
|
||||
|
||||
interface UserContextMenuProps {
|
||||
type: OrganizationUserRole;
|
||||
onClose: () => void;
|
||||
onOpenInviteModal: () => void;
|
||||
}
|
||||
|
||||
export function UserContextMenu({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { mutate: logout } = useLogout();
|
||||
const { isPersonalOrg } = useOrgTypeAndAccess();
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
const settingsNavItems = useSettingsNavItems();
|
||||
const shouldHideSelector = useShouldHideOrgSelector();
|
||||
|
||||
// Filter out org routes since they're handled separately via buttons in this menu
|
||||
const navItems = settingsNavItems.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org" && item.to !== "/settings/org-members",
|
||||
);
|
||||
|
||||
const isMember = type === "member";
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleInviteMemberClick = () => {
|
||||
onOpenInviteModal();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleManageOrganizationMembersClick = () => {
|
||||
navigate("/settings/org-members");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleManageAccountClick = () => {
|
||||
navigate("/settings/org");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="user-context-menu"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-72 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-4 context-menu-box-shadow",
|
||||
"text-sm absolute left-full bottom-0 z-101",
|
||||
)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.ORG$ACCOUNT)}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{!shouldHideSelector && (
|
||||
<div className="w-full relative">
|
||||
<OrgSelector />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMember && !isPersonalOrg && (
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<ContextMenuListItem
|
||||
onClick={handleInviteMemberClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoPersonAddOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageAccountClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoCardOutline className="text-white" size={14} />
|
||||
{t(I18nKey.COMMON$ORGANIZATION)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageOrganizationMembersClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<FiUsers className="text-white shrink-0" size={14} />
|
||||
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
<Divider className="my-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
{React.cloneElement(item.icon, {
|
||||
className: "text-white",
|
||||
width: 14,
|
||||
height: 14,
|
||||
} as React.SVGProps<SVGSVGElement>)}
|
||||
{t(item.text)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
<DocumentIcon className="text-white" width={14} height={14} />
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface BadgeInputProps {
|
||||
value: string[];
|
||||
placeholder?: string;
|
||||
onChange: (value: string[]) => void;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
}
|
||||
|
||||
export function BadgeInput({
|
||||
@@ -15,6 +17,8 @@ export function BadgeInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
className,
|
||||
inputClassName,
|
||||
}: BadgeInputProps) {
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
|
||||
@@ -45,6 +49,7 @@ export function BadgeInput({
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"flex flex-wrap items-center gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value.map((badge, index) => (
|
||||
@@ -69,7 +74,7 @@ export function BadgeInput({
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-grow outline-none bg-transparent"
|
||||
className={cn("flex-grow outline-none bg-transparent", inputClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,21 +3,35 @@ import { cn } from "#/utils/utils";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size: "small" | "large";
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
outerClassName?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size }: LoadingSpinnerProps) {
|
||||
export function LoadingSpinner({
|
||||
size,
|
||||
className,
|
||||
innerClassName,
|
||||
outerClassName,
|
||||
}: LoadingSpinnerProps) {
|
||||
const sizeStyle =
|
||||
size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]";
|
||||
|
||||
return (
|
||||
<div data-testid="loading-spinner" className={cn("relative", sizeStyle)}>
|
||||
<div
|
||||
data-testid="loading-spinner"
|
||||
className={cn("relative", sizeStyle, className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full border-4 border-[#525252] absolute",
|
||||
sizeStyle,
|
||||
innerClassName,
|
||||
)}
|
||||
/>
|
||||
<LoadingSpinnerOuter className={cn("absolute animate-spin", sizeStyle)} />
|
||||
<LoadingSpinnerOuter
|
||||
className={cn("absolute animate-spin", sizeStyle, outerClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function BaseModalDescription({
|
||||
children,
|
||||
}: BaseModalDescriptionProps) {
|
||||
return (
|
||||
<span className="text-xs text-[#A3A3A3]">{children || description}</span>
|
||||
<span className="text-xs text-modal-muted">{children || description}</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,14 @@ import React from "react";
|
||||
interface ModalBackdropProps {
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
|
||||
export function ModalBackdrop({
|
||||
children,
|
||||
onClose,
|
||||
"aria-label": ariaLabel,
|
||||
}: ModalBackdropProps) {
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose?.();
|
||||
@@ -20,7 +25,12 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-60">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 flex items-center justify-center z-60"
|
||||
>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="fixed inset-0 bg-black opacity-60"
|
||||
|
||||
70
frontend/src/components/shared/modals/modal-button-group.tsx
Normal file
70
frontend/src/components/shared/modals/modal-button-group.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ModalButtonGroupProps {
|
||||
primaryText: string;
|
||||
secondaryText?: string;
|
||||
onPrimaryClick?: () => void;
|
||||
onSecondaryClick: () => void;
|
||||
isLoading?: boolean;
|
||||
primaryType?: "button" | "submit";
|
||||
primaryTestId?: string;
|
||||
secondaryTestId?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function ModalButtonGroup({
|
||||
primaryText,
|
||||
secondaryText,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
isLoading = false,
|
||||
primaryType = "button",
|
||||
primaryTestId,
|
||||
secondaryTestId,
|
||||
fullWidth = false,
|
||||
}: ModalButtonGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const closeText = secondaryText ?? t(I18nKey.BUTTON$CLOSE);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 w-full">
|
||||
<BrandButton
|
||||
type={primaryType}
|
||||
variant="primary"
|
||||
onClick={onPrimaryClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
fullWidth ? "w-full" : "grow",
|
||||
)}
|
||||
testId={primaryTestId}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner
|
||||
size="small"
|
||||
className="w-5 h-5"
|
||||
innerClassName="hidden"
|
||||
outerClassName="w-5 h-5"
|
||||
/>
|
||||
) : (
|
||||
primaryText
|
||||
)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onSecondaryClick}
|
||||
className={cn(fullWidth ? "w-full" : "grow")}
|
||||
testId={secondaryTestId}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{closeText}
|
||||
</BrandButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/shared/modals/org-modal.tsx
Normal file
90
frontend/src/components/shared/modals/org-modal.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
import { ModalBody } from "./modal-body";
|
||||
import { ModalButtonGroup } from "./modal-button-group";
|
||||
|
||||
interface OrgModalProps {
|
||||
testId?: string;
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
primaryButtonText: string;
|
||||
secondaryButtonText?: string;
|
||||
onPrimaryClick?: () => void;
|
||||
onClose: () => void;
|
||||
isLoading?: boolean;
|
||||
primaryButtonType?: "button" | "submit";
|
||||
primaryButtonTestId?: string;
|
||||
secondaryButtonTestId?: string;
|
||||
ariaLabel?: string;
|
||||
asForm?: boolean;
|
||||
formAction?: (formData: FormData) => void;
|
||||
fullWidthButtons?: boolean;
|
||||
}
|
||||
|
||||
export function OrgModal({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
onPrimaryClick,
|
||||
onClose,
|
||||
isLoading = false,
|
||||
primaryButtonType = "button",
|
||||
primaryButtonTestId,
|
||||
secondaryButtonTestId,
|
||||
ariaLabel,
|
||||
asForm = false,
|
||||
formAction,
|
||||
fullWidthButtons = false,
|
||||
}: OrgModalProps) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h3 className="text-xl font-bold">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-xs text-modal-muted">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<ModalButtonGroup
|
||||
primaryText={primaryButtonText}
|
||||
secondaryText={secondaryButtonText}
|
||||
onPrimaryClick={onPrimaryClick}
|
||||
onSecondaryClick={onClose}
|
||||
isLoading={isLoading}
|
||||
primaryType={primaryButtonType}
|
||||
primaryTestId={primaryButtonTestId}
|
||||
secondaryTestId={secondaryButtonTestId}
|
||||
fullWidth={fullWidthButtons}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const modalBodyClassName =
|
||||
"items-start rounded-xl p-6 w-sm flex flex-col gap-4 bg-base-secondary border border-tertiary";
|
||||
|
||||
return (
|
||||
<ModalBackdrop
|
||||
onClose={isLoading ? undefined : onClose}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{asForm ? (
|
||||
<form
|
||||
data-testid={testId}
|
||||
action={formAction}
|
||||
noValidate
|
||||
className={modalBodyClassName}
|
||||
>
|
||||
{content}
|
||||
</form>
|
||||
) : (
|
||||
<ModalBody testID={testId} className={modalBodyClassName}>
|
||||
{content}
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -162,6 +162,22 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "ThinkObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$THINK";
|
||||
break;
|
||||
case "GlobObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$GLOB";
|
||||
observationValues = {
|
||||
pattern: event.observation.pattern
|
||||
? trimText(event.observation.pattern, 50)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
case "GrepObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$GREP";
|
||||
observationValues = {
|
||||
pattern: event.observation.pattern
|
||||
? trimText(event.observation.pattern, 50)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
FileEditorObservation,
|
||||
StrReplaceEditorObservation,
|
||||
TaskTrackerObservation,
|
||||
GlobObservation,
|
||||
GrepObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
|
||||
// File Editor Observations
|
||||
@@ -221,6 +223,72 @@ const getFinishObservationContent = (
|
||||
return content;
|
||||
};
|
||||
|
||||
// Glob Observations
|
||||
const getGlobObservationContent = (
|
||||
event: ObservationEvent<GlobObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
// Extract text content from the observation
|
||||
const textContent = observation.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
let content = `**Pattern:** \`${observation.pattern}\`\n`;
|
||||
content += `**Search Path:** \`${observation.search_path}\`\n\n`;
|
||||
|
||||
if (observation.is_error) {
|
||||
content += `**Error:**\n${textContent}`;
|
||||
} else if (observation.files.length === 0) {
|
||||
content += "**Result:** No files found.";
|
||||
} else {
|
||||
content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`;
|
||||
content += observation.files.map((f) => `- \`${f}\``).join("\n");
|
||||
}
|
||||
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Grep Observations
|
||||
const getGrepObservationContent = (
|
||||
event: ObservationEvent<GrepObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
// Extract text content from the observation
|
||||
const textContent = observation.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
let content = `**Pattern:** \`${observation.pattern}\`\n`;
|
||||
content += `**Search Path:** \`${observation.search_path}\`\n`;
|
||||
if (observation.include_pattern) {
|
||||
content += `**Include:** \`${observation.include_pattern}\`\n`;
|
||||
}
|
||||
content += "\n";
|
||||
|
||||
if (observation.is_error) {
|
||||
content += `**Error:**\n${textContent}`;
|
||||
} else if (observation.matches.length === 0) {
|
||||
content += "**Result:** No matches found.";
|
||||
} else {
|
||||
content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`;
|
||||
content += observation.matches.map((f) => `- \`${f}\``).join("\n");
|
||||
}
|
||||
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export const getObservationContent = (event: ObservationEvent): string => {
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
@@ -264,6 +332,16 @@ export const getObservationContent = (event: ObservationEvent): string => {
|
||||
event as ObservationEvent<FinishObservation>,
|
||||
);
|
||||
|
||||
case "GlobObservation":
|
||||
return getGlobObservationContent(
|
||||
event as ObservationEvent<GlobObservation>,
|
||||
);
|
||||
|
||||
case "GrepObservation":
|
||||
return getGrepObservationContent(
|
||||
event as ObservationEvent<GrepObservation>,
|
||||
);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ const renderUserMessageWithSkillReady = (
|
||||
);
|
||||
} catch (error) {
|
||||
// If skill ready event creation fails, just render the user message
|
||||
// Failed to create skill ready event, fallback to user message
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={messageEvent}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FiUsers, FiBriefcase } from "react-icons/fi";
|
||||
import CreditCardIcon from "#/icons/credit-card.svg?react";
|
||||
import KeyIcon from "#/icons/key.svg?react";
|
||||
import ServerProcessIcon from "#/icons/server-process.svg?react";
|
||||
@@ -53,6 +54,16 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
{
|
||||
to: "/settings/org-members",
|
||||
text: "Organization Members",
|
||||
icon: <FiUsers size={22} />,
|
||||
},
|
||||
{
|
||||
to: "/settings/org",
|
||||
text: "Organization",
|
||||
icon: <FiBriefcase size={22} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
|
||||
28
frontend/src/context/use-selected-organization.ts
Normal file
28
frontend/src/context/use-selected-organization.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useRevalidator } from "react-router";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
interface SetOrganizationIdOptions {
|
||||
/** Skip route revalidation. Useful for initial auto-selection to avoid duplicate API calls. */
|
||||
skipRevalidation?: boolean;
|
||||
}
|
||||
|
||||
export const useSelectedOrganizationId = () => {
|
||||
const revalidator = useRevalidator();
|
||||
const { organizationId, setOrganizationId: setOrganizationIdStore } =
|
||||
useSelectedOrganizationStore();
|
||||
|
||||
const setOrganizationId = (
|
||||
newOrganizationId: string | null,
|
||||
options?: SetOrganizationIdOptions,
|
||||
) => {
|
||||
setOrganizationIdStore(newOrganizationId);
|
||||
// Revalidate route to ensure the latest orgId is used.
|
||||
// This is useful for redirecting the user away from admin-only org pages.
|
||||
// Skip revalidation for initial auto-selection to avoid duplicate API calls.
|
||||
if (!options?.skipRevalidation) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
return { organizationId, setOrganizationId };
|
||||
};
|
||||
36
frontend/src/hooks/mutation/use-delete-organization.ts
Normal file
36
frontend/src/hooks/mutation/use-delete-organization.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useDeleteOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { organizationId, setOrganizationId } = useSelectedOrganizationId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
if (!organizationId) throw new Error("Organization ID is required");
|
||||
return organizationService.deleteOrganization({ orgId: organizationId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Remove stale cache BEFORE clearing the selected organization.
|
||||
// This prevents useAutoSelectOrganization from using the old currentOrgId
|
||||
// when it runs during the re-render triggered by setOrganizationId(null).
|
||||
// Using removeQueries (not invalidateQueries) ensures stale data is gone immediately.
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["organizations"],
|
||||
exact: true,
|
||||
});
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["organizations", organizationId],
|
||||
});
|
||||
|
||||
// Now clear the selected organization - useAutoSelectOrganization will
|
||||
// wait for fresh data since the cache is empty
|
||||
setOrganizationId(null);
|
||||
|
||||
navigate("/");
|
||||
},
|
||||
});
|
||||
};
|
||||
38
frontend/src/hooks/mutation/use-invite-members-batch.ts
Normal file
38
frontend/src/hooks/mutation/use-invite-members-batch.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export const useInviteMembersBatch = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ emails }: { emails: string[] }) => {
|
||||
if (!organizationId) {
|
||||
throw new Error("Organization ID is required");
|
||||
}
|
||||
return organizationService.inviteMembers({
|
||||
orgId: organizationId,
|
||||
emails,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.ORG$INVITE_MEMBERS_SUCCESS));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizations", "members", organizationId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ORG$INVITE_MEMBERS_ERROR));
|
||||
},
|
||||
});
|
||||
};
|
||||
38
frontend/src/hooks/mutation/use-remove-member.ts
Normal file
38
frontend/src/hooks/mutation/use-remove-member.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export const useRemoveMember = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId }: { userId: string }) => {
|
||||
if (!organizationId) {
|
||||
throw new Error("Organization ID is required");
|
||||
}
|
||||
return organizationService.removeMember({
|
||||
orgId: organizationId,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.ORG$REMOVE_MEMBER_SUCCESS));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizations", "members", organizationId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ORG$REMOVE_MEMBER_ERROR));
|
||||
},
|
||||
});
|
||||
};
|
||||
36
frontend/src/hooks/mutation/use-switch-organization.ts
Normal file
36
frontend/src/hooks/mutation/use-switch-organization.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMatch, useNavigate } from "react-router";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
|
||||
export const useSwitchOrganization = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { setOrganizationId } = useSelectedOrganizationId();
|
||||
const navigate = useNavigate();
|
||||
const conversationMatch = useMatch("/conversations/:conversationId");
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (orgId: string) =>
|
||||
organizationService.switchOrganization({ orgId }),
|
||||
onSuccess: (_, orgId) => {
|
||||
// Invalidate the target org's /me query to ensure fresh data on every switch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizations", orgId, "me"],
|
||||
});
|
||||
// Update local state
|
||||
setOrganizationId(orgId);
|
||||
// Invalidate settings for the new org context
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
// Invalidate conversations to fetch data for the new org context
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
// Remove all individual conversation queries to clear any stale/null data
|
||||
// from the previous org context
|
||||
queryClient.removeQueries({ queryKey: ["user", "conversation"] });
|
||||
|
||||
// Redirect to home if on a conversation page since org context has changed
|
||||
if (conversationMatch) {
|
||||
navigate("/");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -33,6 +33,20 @@ export const useUnifiedResumeConversationSandbox = () => {
|
||||
providers?: Provider[];
|
||||
version?: "V0" | "V1";
|
||||
}) => {
|
||||
// Guard: If conversation is no longer in cache and no explicit version provided,
|
||||
// skip the mutation. This handles race conditions like org switching where cache
|
||||
// is cleared before the mutation executes.
|
||||
// We return undefined (not throw) to avoid triggering the global MutationCache.onError
|
||||
// handler which would display an error toast to the user.
|
||||
const cachedConversation = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversation",
|
||||
variables.conversationId,
|
||||
]);
|
||||
if (!cachedConversation && !variables.version) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use provided version or fallback to cache lookup
|
||||
const version =
|
||||
variables.version ||
|
||||
|
||||
46
frontend/src/hooks/mutation/use-update-member-role.ts
Normal file
46
frontend/src/hooks/mutation/use-update-member-role.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export const useUpdateMemberRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { organizationId } = useSelectedOrganizationId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
role,
|
||||
}: {
|
||||
userId: string;
|
||||
role: OrganizationUserRole;
|
||||
}) => {
|
||||
if (!organizationId) {
|
||||
throw new Error("Organization ID is required to update member role");
|
||||
}
|
||||
return organizationService.updateMember({
|
||||
orgId: organizationId,
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.ORG$UPDATE_ROLE_SUCCESS));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizations", "members", organizationId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ORG$UPDATE_ROLE_ERROR));
|
||||
},
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user