Merge main and fix settings schema CI

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-03-15 19:51:27 +00:00
180 changed files with 19168 additions and 5670 deletions

2
.gitignore vendored
View File

@@ -234,6 +234,8 @@ yarn-error.log*
logs
ralph/
# agent
.envrc
/workspace

3676
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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',
)

View 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)

View File

@@ -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
View File

@@ -8,3 +8,4 @@ node_modules/
/blob-report/
/playwright/.cache/
.react-router/
ralph/

View File

@@ -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");
});
});
});

View File

@@ -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: {

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});

View File

@@ -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(", ")}`,
);
}
});
});

View 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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();

View File

@@ -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");
});
});

View File

@@ -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,
});

View 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 { 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");
});
});

View 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");
});
});

View 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");
});
});

View 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);
});
});
});

View 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);
});
});
});
});

View File

@@ -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 });

View File

@@ -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");
});
});

View 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");
});
});

View File

@@ -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();

View 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();
});
});
});

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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 }),

View 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();
});
});
});

File diff suppressed because it is too large Load Diff

View 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");
});
});

View File

@@ -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,
);
}
}
});
});
});

View File

@@ -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();

View File

@@ -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");

View File

@@ -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();

View File

@@ -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");
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View 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();
});
});
});

View File

@@ -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,
},

View File

@@ -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);

View File

@@ -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;
},
};

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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>
);
}

View 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),
})) || []
}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
)}
</>
);
}

View File

@@ -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 && (

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View 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>
);
}

View 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>
);
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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[] = [

View 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 };
};

View 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("/");
},
});
};

View 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));
},
});
};

View 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));
},
});
};

View 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("/");
}
},
});
};

View File

@@ -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 ||

View 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