Files
OpenHands/tests/unit/storage/data_models/test_settings.py
T
cc80397ecd chore: bump SDK packages to v1.31.2 (#15156)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-07-07 04:13:21 -06:00

961 lines
34 KiB
Python

import importlib
import warnings
from unittest.mock import patch
import pytest
from pydantic import SecretStr, ValidationError
import openhands.app_server.settings.settings_models as settings_module
from openhands.app_server.settings.llm_profiles import ProfileNotFoundError
from openhands.app_server.settings.settings_models import (
MarketplaceRegistration,
Settings,
)
from openhands.app_server.settings.settings_router import LITE_LLM_API_URL
from openhands.sdk.llm import LLM
from openhands.sdk.settings import (
AGENT_SETTINGS_SCHEMA_VERSION,
ConversationSettings,
OpenHandsAgentSettings,
)
from openhands.sdk.settings.model import CondenserSettings, VerificationSettings
def test_settings_handles_sensitive_data():
settings = Settings(
language='en',
agent_settings=OpenHandsAgentSettings(
agent='test-agent',
llm=LLM(
model='test-model',
api_key=SecretStr('test-key'),
base_url='https://test.example.com',
),
),
conversation_settings=ConversationSettings(
max_iterations=100,
security_analyzer='llm',
confirmation_mode=True,
),
remote_runtime_resource_factor=2,
)
llm_api_key = settings.agent_settings.llm.api_key
assert str(llm_api_key) == '**********'
assert llm_api_key.get_secret_value() == 'test-key'
def test_settings_loads_persisted_settings_via_sdk_loaders():
loaded_agent_settings = OpenHandsAgentSettings(agent='migrated-agent')
loaded_conversation_settings = ConversationSettings(max_iterations=77)
with (
patch.object(
settings_module,
'validate_agent_settings',
return_value=loaded_agent_settings,
) as agent_loader,
patch.object(
ConversationSettings,
'from_persisted',
return_value=loaded_conversation_settings,
) as conversation_loader,
):
settings = Settings(
agent_settings={'legacy': True},
conversation_settings={'legacy': True},
)
agent_loader.assert_called_once_with({'legacy': True})
conversation_loader.assert_called_once_with({'legacy': True})
assert settings.agent_settings.agent == 'migrated-agent'
assert settings.conversation_settings.max_iterations == 77
def test_settings_update_deep_merges_agent_settings():
"""Updating agent_settings with a partial dict must not overwrite sibling sub-fields."""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='existing-model', api_key=SecretStr('existing-key')),
condenser=CondenserSettings(enabled=True, max_size=200),
),
)
settings.update({'agent_settings_diff': {'condenser': {'max_size': 300}}})
assert settings.agent_settings.llm.model == 'existing-model'
assert settings.agent_settings.llm.api_key.get_secret_value() == 'existing-key'
assert settings.agent_settings.condenser.max_size == 300
assert settings.agent_settings.condenser.enabled is True
def test_settings_preserve_agent_settings():
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(
model='test-model',
api_key=SecretStr('test-key'),
litellm_extra_body={'metadata': {'tier': 'pro'}},
),
verification=VerificationSettings(
critic_enabled=True,
critic_mode='all_actions',
),
),
)
assert settings.agent_settings.llm.api_key.get_secret_value() == 'test-key'
dump = settings.agent_settings.model_dump(
mode='json', context={'expose_secrets': True}
)
assert dump['schema_version'] == AGENT_SETTINGS_SCHEMA_VERSION
assert dump['llm']['model'] == 'test-model'
assert dump['llm']['api_key'] == 'test-key'
assert dump['verification']['critic_enabled'] is True
assert dump['verification']['critic_mode'] == 'all_actions'
assert dump['llm']['litellm_extra_body'] == {'metadata': {'tier': 'pro'}}
def test_settings_to_agent_settings_uses_agent_vals():
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(
model='sdk-model',
base_url='https://sdk.example.com',
litellm_extra_body={'metadata': {'tier': 'enterprise'}},
),
condenser=CondenserSettings(enabled=False, max_size=88),
verification=VerificationSettings(
critic_enabled=True, critic_mode='all_actions'
),
),
)
agent_settings = settings.to_agent_settings()
assert agent_settings.llm.model == 'sdk-model'
assert agent_settings.llm.base_url == 'https://sdk.example.com'
assert agent_settings.llm.litellm_extra_body == {'metadata': {'tier': 'enterprise'}}
assert agent_settings.condenser.enabled is False
assert agent_settings.condenser.max_size == 88
assert agent_settings.verification.critic_enabled is True
assert agent_settings.verification.critic_mode == 'all_actions'
def test_settings_agent_settings_keeps_sdk_mcp_shape_canonical():
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='sdk-model'),
mcp_config={
'sse_server': {
'url': 'https://example.com/sse',
'transport': 'sse',
}
},
),
)
mcp_config = settings.agent_settings.mcp_config
assert mcp_config is not None
assert 'sse_server' in mcp_config
assert mcp_config['sse_server'].transport == 'sse'
assert mcp_config['sse_server'].url == 'https://example.com/sse'
api_values = settings.agent_settings.model_dump(mode='json')
assert 'sse_server' in api_values['mcp_config']
def test_settings_update_mcp_config():
settings = Settings(
agent_settings=OpenHandsAgentSettings(llm=LLM(model='sdk-model'))
)
settings.update(
{
'agent_settings_diff': {
'mcp_config': {
'mcpServers': {
'custom': {
'transport': 'http',
'url': 'https://example.com/mcp',
}
}
}
}
}
)
mcp = settings.agent_settings.mcp_config
assert mcp is not None
assert 'custom' in mcp
assert mcp['custom'].transport == 'http'
assert mcp['custom'].url == 'https://example.com/mcp'
def test_settings_update_replaces_existing_mcp_servers():
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='sdk-model'),
mcp_config={
'stale': {
'transport': 'sse',
'url': 'https://example.com/stale',
}
},
)
)
settings.update(
{
'agent_settings_diff': {
'mcp_config': {
'mcpServers': {
'fresh': {
'transport': 'http',
'url': 'https://example.com/fresh',
}
}
}
}
}
)
mcp = settings.agent_settings.mcp_config
assert mcp is not None
assert set(mcp) == {'fresh'}
assert mcp['fresh'].url == 'https://example.com/fresh'
def test_settings_update_can_clear_mcp_config():
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='sdk-model'),
mcp_config={
'custom': {
'transport': 'http',
'url': 'https://example.com/mcp',
}
},
)
)
settings.update({'agent_settings_diff': {'mcp_config': None}})
# The SDK normalizes ``None`` to ``{}`` so cleared configs round-trip as an
# empty server map rather than ``None``.
assert settings.agent_settings.mcp_config == {}
def test_settings_update_batch():
settings = Settings()
settings.update(
{
'language': 'fr',
'agent_settings_diff': {
'agent': 'TestAgent',
'llm': {'model': 'new-model', 'api_key': 'new-key'},
},
'conversation_settings_diff': {
'max_iterations': 200,
},
}
)
assert settings.language == 'fr'
assert settings.agent_settings.agent == 'TestAgent'
assert settings.agent_settings.llm.model == 'new-model'
assert settings.agent_settings.llm.api_key.get_secret_value() == 'new-key'
assert settings.conversation_settings.max_iterations == 200
# ── LLM profiles: Settings-integration tests ────────────────────────
# Pure LLMProfiles behaviour lives in test_llm_profiles.py.
def test_switch_to_profile_updates_agent_settings_llm():
settings = Settings()
settings.llm_profiles.save('my-profile', LLM(model='openai/gpt-4o'))
settings.switch_to_profile('my-profile')
assert settings.agent_settings.llm.model == 'openai/gpt-4o'
assert settings.llm_profiles.active == 'my-profile'
def test_switch_to_nonexistent_profile_raises():
settings = Settings()
with pytest.raises(ProfileNotFoundError) as exc_info:
settings.switch_to_profile('nonexistent')
assert exc_info.value.name == 'nonexistent'
assert settings.llm_profiles.active is None
def test_llm_profiles_masking_and_roundtrip():
"""Masked by default, exposed with context, and reconstructible via ``model_validate``."""
settings = Settings()
settings.llm_profiles.save(
'p', LLM(model='openai/gpt-4o', api_key=SecretStr('secret'))
)
masked = settings.model_dump(mode='json')
exposed = settings.model_dump(mode='json', context={'expose_secrets': True})
assert masked['llm_profiles']['profiles']['p']['api_key'] != 'secret'
assert exposed['llm_profiles']['profiles']['p']['api_key'] == 'secret'
rehydrated = Settings.model_validate(exposed)
assert rehydrated.llm_profiles.get('p').api_key.get_secret_value() == 'secret'
def test_switch_to_profile_preserves_other_agent_settings():
"""Switching the LLM must not wipe condenser/verification/mcp_config.
Real user: has condenser+verification configured, switches LLM profile —
expects everything else to stay. A bare-field reassign in
``switch_to_profile`` would silently drop those sibling configs.
"""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='openai/gpt-4o'),
condenser=CondenserSettings(enabled=True, max_size=321),
verification=VerificationSettings(
critic_enabled=True, critic_mode='all_actions'
),
mcp_config={
's': {
'transport': 'http',
'url': 'https://example.com/mcp',
}
},
),
)
settings.llm_profiles.save('p', LLM(model='anthropic/claude-opus-4'))
settings.switch_to_profile('p')
assert settings.agent_settings.llm.model == 'anthropic/claude-opus-4'
assert settings.agent_settings.condenser.max_size == 321
assert settings.agent_settings.verification.critic_mode == 'all_actions'
assert settings.agent_settings.mcp_config is not None
assert 's' in settings.agent_settings.mcp_config
def test_delete_active_profile_promotes_remaining_one():
settings = Settings()
settings.llm_profiles.save('a', LLM(model='openai/gpt-4o'))
settings.llm_profiles.save('b', LLM(model='anthropic/claude-opus-4'))
settings.switch_to_profile('a')
assert settings.delete_profile('a') is True
assert 'a' not in settings.llm_profiles.profiles
assert settings.llm_profiles.active == 'b'
assert settings.agent_settings.llm.model == 'anthropic/claude-opus-4'
def test_delete_inactive_profile_does_not_touch_active():
settings = Settings()
settings.llm_profiles.save('a', LLM(model='openai/gpt-4o'))
settings.llm_profiles.save('b', LLM(model='anthropic/claude-opus-4'))
settings.switch_to_profile('a')
assert settings.delete_profile('b') is True
assert settings.llm_profiles.active == 'a'
assert settings.agent_settings.llm.model == 'openai/gpt-4o'
def test_delete_only_profile_clears_active():
settings = Settings()
settings.llm_profiles.save('only', LLM(model='openai/gpt-4o'))
settings.switch_to_profile('only')
assert settings.delete_profile('only') is True
assert settings.llm_profiles.profiles == {}
assert settings.llm_profiles.active is None
def test_delete_missing_profile_returns_false():
settings = Settings()
assert settings.delete_profile('nope') is False
def test_update_ignores_llm_profiles_payload():
"""``Settings.update`` refuses to mutate ``llm_profiles``; profile changes
must go through the dedicated endpoints (which enforce name rules, the
count cap, and the per-user lock)."""
settings = Settings()
settings.update(
{
'llm_profiles': {
'profiles': {'X': {'model': 'openai/gpt-4o'}},
'active': 'X',
}
}
)
assert settings.llm_profiles.profiles == {}
assert settings.llm_profiles.active is None
def test_update_clears_active_when_llm_diverges():
"""Editing agent_settings.llm via ``update`` must drop a now-stale active profile."""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='openai/gpt-4o', api_key=SecretStr('sk-a'))
)
)
settings.llm_profiles.save(
'p', LLM(model='openai/gpt-4o', api_key=SecretStr('sk-a'))
)
settings.switch_to_profile('p')
assert settings.llm_profiles.active == 'p'
settings.update(
{'agent_settings_diff': {'llm': {'model': 'anthropic/claude-opus-4'}}}
)
assert settings.llm_profiles.active is None
def test_update_keeps_active_when_llm_unchanged():
"""A no-op LLM update must not spuriously clear ``active``."""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='openai/gpt-4o', api_key=SecretStr('sk-a'))
)
)
settings.llm_profiles.save(
'p', LLM(model='openai/gpt-4o', api_key=SecretStr('sk-a'))
)
settings.switch_to_profile('p')
# Update an unrelated field.
settings.update({'language': 'fr'})
assert settings.llm_profiles.active == 'p'
def test_settings_update_batch_accepts_diff_keys():
settings = Settings()
settings.update(
{
'agent_settings_diff': {
'agent': 'DiffAgent',
'llm': {'model': 'diff-model', 'api_key': 'diff-key'},
},
'conversation_settings_diff': {
'max_iterations': 123,
},
}
)
assert settings.agent_settings.agent == 'DiffAgent'
assert settings.agent_settings.llm.model == 'diff-model'
assert settings.agent_settings.llm.api_key.get_secret_value() == 'diff-key'
assert settings.conversation_settings.max_iterations == 123
def test_settings_update_rejects_legacy_nested_keys():
settings = Settings()
with pytest.raises(ValueError, match=r'Use \*_diff nested settings payloads'):
settings.update({'agent_settings': {'agent': 'LegacyAgent'}})
def test_settings_no_pydantic_frozen_field_warning():
"""Test that Settings model does not trigger Pydantic UnsupportedFieldAttributeWarning."""
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
importlib.reload(settings_module)
frozen_warnings = [
warning for warning in w if 'frozen' in str(warning.message).lower()
]
assert len(frozen_warnings) == 0, (
f'Pydantic frozen field warnings found: {[str(w.message) for w in frozen_warnings]}'
)
def test_litellm_proxy_with_openhands_proxy_keeps_prefix_for_display():
"""Display data no longer reverse-maps LiteLLM proxy model names."""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(
model='litellm_proxy/claude-opus-4-5-20251101',
base_url=LITE_LLM_API_URL,
)
)
)
api_data = settings.get_agent_settings_display()
assert api_data['llm']['model'] == 'litellm_proxy/claude-opus-4-5-20251101'
def test_litellm_proxy_custom_endpoint_keeps_prefix():
"""Test that custom litellm_proxy endpoints keep their litellm_proxy/ prefix."""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(
model='litellm_proxy/gpt-5.3-codex',
base_url='http://custom-proxy.example.com:4000',
)
)
)
# Internal representation
assert settings.agent_settings.llm.model == 'litellm_proxy/gpt-5.3-codex'
# Display should NOT convert to openhands/ because it's a custom endpoint
api_data = settings.get_agent_settings_display()
assert api_data['llm']['model'] == 'litellm_proxy/gpt-5.3-codex'
def test_openhands_model_display_does_not_reverse_map():
"""Display data reflects the LLM model shape provided by the SDK."""
settings = Settings(
agent_settings=OpenHandsAgentSettings(
llm=LLM(model='openhands/claude-opus-4-5-20251101')
)
)
api_data = settings.get_agent_settings_display()
assert api_data['llm']['model'] == settings.agent_settings.llm.model
# --- Tests for MarketplaceRegistration ---
class TestMarketplaceRegistration:
"""Tests for MarketplaceRegistration model."""
def test_basic_registration(self):
"""Test creating a basic marketplace registration."""
reg = MarketplaceRegistration(
name='test-marketplace',
source='github:owner/repo',
)
assert reg.name == 'test-marketplace'
assert reg.source == 'github:owner/repo'
assert reg.ref is None
assert reg.repo_path is None
assert reg.auto_load is False # Defaults to False, not None
def test_registration_with_auto_load(self):
"""Test registration with auto_load=True."""
reg = MarketplaceRegistration(
name='public',
source='github:OpenHands/skills',
auto_load=True,
)
assert reg.auto_load
def test_registration_with_ref(self):
"""Test registration with specific ref."""
reg = MarketplaceRegistration(
name='versioned',
source='github:owner/repo',
ref='v1.0.0',
)
assert reg.ref == 'v1.0.0'
def test_registration_with_repo_path(self):
"""Test registration with repo_path for monorepos."""
reg = MarketplaceRegistration(
name='monorepo-marketplace',
source='github:acme/monorepo',
repo_path='marketplaces/internal',
)
assert reg.repo_path == 'marketplaces/internal'
def test_repo_path_validation_rejects_absolute(self):
"""Test that absolute repo_path is rejected."""
with pytest.raises(ValidationError, match='must be relative'):
MarketplaceRegistration(
name='test',
source='github:owner/repo',
repo_path='/absolute/path',
)
def test_repo_path_validation_rejects_traversal(self):
"""Test that parent directory traversal is rejected."""
with pytest.raises(ValidationError, match="cannot contain '..'"):
MarketplaceRegistration(
name='test',
source='github:owner/repo',
repo_path='../escape/path',
)
def test_repo_path_validation_rejects_url(self):
"""A repository URL pasted into repo_path is rejected (not a subdir)."""
with pytest.raises(ValidationError, match='not a URL'):
MarketplaceRegistration(
name='test',
source='github:OpenHands/extensions',
repo_path='https://github.com/OpenHands/extensions',
)
def test_serialization(self):
"""Test that MarketplaceRegistration serializes with standard pydantic."""
reg = MarketplaceRegistration(
name='test',
source='github:owner/repo',
ref='main',
repo_path='plugins',
auto_load=True,
)
data = reg.model_dump()
assert data == {
'name': 'test',
'source': 'github:owner/repo',
'ref': 'main',
'repo_path': 'plugins',
'auto_load': True,
'scope': None,
}
def test_serialization_excludes_none_for_wire_payload(self):
"""exclude_none drops unset optional fields (as sent to the agent-server)."""
reg = MarketplaceRegistration(name='test', source='github:owner/repo')
assert reg.model_dump(exclude_none=True, exclude={'scope'}) == {
'name': 'test',
'source': 'github:owner/repo',
'auto_load': False,
}
# --- Name validation tests ---
def test_name_validation_rejects_empty(self):
"""Test that empty name is rejected."""
with pytest.raises(ValidationError, match='name cannot be empty'):
MarketplaceRegistration(name='', source='github:owner/repo')
def test_name_validation_rejects_whitespace_only(self):
"""Test that whitespace-only name is rejected."""
with pytest.raises(ValidationError, match='name cannot be empty'):
MarketplaceRegistration(name=' ', source='github:owner/repo')
def test_name_validation_rejects_invalid_chars(self):
"""Test that names with invalid characters are rejected."""
with pytest.raises(ValidationError, match='must start with a letter'):
MarketplaceRegistration(name='123-invalid', source='github:owner/repo')
def test_name_validation_rejects_spaces(self):
"""Test that names with spaces are rejected."""
with pytest.raises(ValidationError, match='must start with a letter'):
MarketplaceRegistration(name='invalid name', source='github:owner/repo')
def test_name_validation_strips_whitespace(self):
"""Test that name whitespace is stripped."""
reg = MarketplaceRegistration(name=' valid-name ', source='github:owner/repo')
assert reg.name == 'valid-name'
# --- Source validation tests ---
def test_source_validation_github_format(self):
"""Test valid github:owner/repo source."""
reg = MarketplaceRegistration(name='test', source='github:my-org/my-repo')
assert reg.source == 'github:my-org/my-repo'
def test_source_validation_https_url(self):
"""Test valid HTTPS git URL source."""
reg = MarketplaceRegistration(
name='test', source='https://github.com/owner/repo'
)
assert reg.source == 'https://github.com/owner/repo'
def test_source_validation_ssh_url(self):
"""Test valid SSH git URL source."""
reg = MarketplaceRegistration(name='test', source='git@github.com:owner/repo')
assert reg.source == 'git@github.com:owner/repo'
def test_source_validation_relative_path(self):
"""Test valid relative local path source."""
reg = MarketplaceRegistration(name='test', source='local/path/to/skills')
assert reg.source == 'local/path/to/skills'
def test_source_validation_rejects_empty(self):
"""Test that empty source is rejected."""
with pytest.raises(ValidationError, match='source cannot be empty'):
MarketplaceRegistration(name='test', source='')
def test_source_validation_rejects_absolute_path(self):
"""Test that absolute local path is rejected."""
with pytest.raises(ValidationError, match='must be relative'):
MarketplaceRegistration(name='test', source='/absolute/path')
def test_source_validation_rejects_parent_traversal(self):
"""Test that parent directory traversal in source is rejected."""
with pytest.raises(ValidationError, match="cannot contain '..'"):
MarketplaceRegistration(name='test', source='../escape/path')
# --- Tests for Settings.registered_marketplaces ---
class TestSettingsRegisteredMarketplaces:
"""Tests for registered_marketplaces field in Settings."""
def test_settings_default_empty_registered_marketplaces(self):
"""Test that Settings defaults to empty registered_marketplaces."""
settings = Settings()
assert settings.registered_marketplaces == []
def test_settings_with_registered_marketplaces(self):
"""Test Settings with registered_marketplaces configured."""
marketplaces = [
MarketplaceRegistration(
name='public',
source='github:OpenHands/skills',
auto_load=True,
),
MarketplaceRegistration(
name='team',
source='github:acme/plugins',
),
]
settings = Settings(registered_marketplaces=marketplaces)
assert len(settings.registered_marketplaces) == 2
assert settings.registered_marketplaces[0].name == 'public'
assert settings.registered_marketplaces[0].auto_load is True
assert settings.registered_marketplaces[1].name == 'team'
assert (
settings.registered_marketplaces[1].auto_load is False
) # Defaults to False
def test_settings_serialization_with_registered_marketplaces(self):
"""Test Settings serialization includes registered_marketplaces."""
marketplaces = [
MarketplaceRegistration(
name='test',
source='github:owner/repo',
auto_load=True,
),
]
settings = Settings(registered_marketplaces=marketplaces)
data = settings.model_dump()
assert 'registered_marketplaces' in data
assert len(data['registered_marketplaces']) == 1
assert data['registered_marketplaces'][0]['name'] == 'test'
assert data['registered_marketplaces'][0]['auto_load']
def test_settings_from_dict_with_registered_marketplaces(self):
"""Test creating Settings from dict with registered_marketplaces."""
data = {
'registered_marketplaces': [
{
'name': 'custom',
'source': 'github:custom/repo',
'ref': 'v1.0.0',
'auto_load': True,
}
]
}
settings = Settings.model_validate(data)
assert len(settings.registered_marketplaces) == 1
assert settings.registered_marketplaces[0].name == 'custom'
assert settings.registered_marketplaces[0].ref == 'v1.0.0'
def test_settings_allows_unique_marketplace_names(self):
"""Test that unique marketplace names are allowed."""
settings = Settings(
registered_marketplaces=[
MarketplaceRegistration(
name='first',
source='github:owner/repo1',
),
MarketplaceRegistration(
name='second',
source='github:owner/repo2',
),
]
)
assert len(settings.registered_marketplaces) == 2
def test_settings_with_mixed_marketplaces(self):
"""Test Settings with marketplace containing all optional fields."""
settings = Settings(
registered_marketplaces=[
MarketplaceRegistration(
name='full-featured',
source='github:owner/repo',
ref='v1.0.0',
repo_path='marketplaces/internal',
auto_load=True,
),
MarketplaceRegistration(
name='minimal',
source='github:owner/minimal',
),
MarketplaceRegistration(
name='auto-load-only',
source='github:owner/auto',
auto_load=True,
),
]
)
assert len(settings.registered_marketplaces) == 3
# Verify full-featured marketplace
full = settings.registered_marketplaces[0]
assert full.name == 'full-featured'
assert full.ref == 'v1.0.0'
assert full.repo_path == 'marketplaces/internal'
assert full.auto_load
# Verify minimal marketplace has None for optional fields and False for auto_load
minimal = settings.registered_marketplaces[1]
assert minimal.ref is None
assert minimal.repo_path is None
assert minimal.auto_load is False # Defaults to False, not None
def test_settings_registered_marketplaces_serialization_roundtrip(self):
"""Test that marketplace data survives serialization roundtrip."""
original = Settings(
registered_marketplaces=[
MarketplaceRegistration(
name='test',
source='github:owner/repo',
ref='main',
auto_load=True,
),
]
)
# Serialize to dict
data = original.model_dump()
assert 'registered_marketplaces' in data
assert len(data['registered_marketplaces']) == 1
# Deserialize back
restored = Settings.model_validate(data)
assert len(restored.registered_marketplaces) == 1
assert restored.registered_marketplaces[0].name == 'test'
assert restored.registered_marketplaces[0].ref == 'main'
assert restored.registered_marketplaces[0].auto_load
class TestMarketplaceRegistrationValidationEdgeCases:
"""Edge case tests for MarketplaceRegistration validation."""
def test_name_with_hyphens_and_underscores(self):
"""Test that names with hyphens and underscores are valid."""
reg = MarketplaceRegistration(
name='my_marketplace-name', source='github:owner/repo'
)
assert reg.name == 'my_marketplace-name'
def test_name_with_numbers(self):
"""Test that names with numbers are valid."""
reg = MarketplaceRegistration(name='marketplace123', source='github:owner/repo')
assert reg.name == 'marketplace123'
def test_name_strips_surrounding_whitespace(self):
"""Test that surrounding whitespace is stripped from name."""
reg = MarketplaceRegistration(name=' trimmed ', source='github:owner/repo')
assert reg.name == 'trimmed'
def test_source_strips_surrounding_whitespace(self):
"""Test that surrounding whitespace is stripped from source."""
reg = MarketplaceRegistration(name='test', source=' github:owner/repo ')
assert reg.source == 'github:owner/repo'
def test_local_path_with_subdirectories(self):
"""Test that local paths with subdirectories are valid."""
reg = MarketplaceRegistration(
name='nested',
source='marketplaces/team/internal',
)
assert reg.source == 'marketplaces/team/internal'
def test_source_validation_rejects_leading_dot(self):
"""Test that sources starting with dot are rejected (hidden files)."""
with pytest.raises(ValidationError, match='source must be'):
MarketplaceRegistration(name='test', source='.hidden/repo')
def test_source_validation_rejects_double_dot_in_middle(self):
"""Test that sources with .. in middle are rejected."""
with pytest.raises(ValidationError, match="cannot contain '..'"):
MarketplaceRegistration(name='test', source='path/../escape')
def test_source_validation_rejects_git_protocol(self):
"""Test that git:// protocol URLs are valid."""
reg = MarketplaceRegistration(
name='test',
source='git://github.com/owner/repo',
)
assert reg.source == 'git://github.com/owner/repo'
def test_repo_path_validation_rejects_double_dot(self):
"""Test that repo_path with .. is rejected."""
with pytest.raises(ValidationError, match="cannot contain '..'"):
MarketplaceRegistration(
name='test',
source='github:owner/repo',
repo_path='path/../escape',
)
class TestSettingsDuplicateMarketplaceNames:
"""Settings construction and marketplace name handling.
Uniqueness is enforced on the write paths (not on model construction), so a
legacy row with duplicate names can never lock a user out of settings.
"""
def test_settings_tolerates_duplicate_marketplace_names(self):
"""Constructing Settings from duplicate stored names must not raise."""
# Arrange / Act - duplicate names (e.g. legacy data) must load cleanly.
settings = Settings(
registered_marketplaces=[
MarketplaceRegistration(name='plugins', source='github:owner/repo1'),
MarketplaceRegistration(name='plugins', source='github:owner/repo2'),
]
)
# Assert
assert len(settings.registered_marketplaces) == 2
def test_settings_allows_same_source_different_names(self):
"""Test that same source with different names is allowed."""
settings = Settings(
registered_marketplaces=[
MarketplaceRegistration(
name='plugins-1',
source='github:owner/repo',
),
MarketplaceRegistration(
name='plugins-2', # Different name, same source
source='github:owner/repo',
),
]
)
assert len(settings.registered_marketplaces) == 2
def test_settings_allows_empty_marketplaces(self):
"""Test that Settings allows empty registered_marketplaces."""
settings = Settings(registered_marketplaces=[])
assert settings.registered_marketplaces == []
def test_settings_allows_none_marketplaces(self):
"""Test that Settings allows None registered_marketplaces (uses default)."""
settings = Settings()
assert settings.registered_marketplaces == []
def test_git_full_clone_defaults_to_false_and_updates():
settings = Settings()
assert settings.git_full_clone is False
settings.update({'git_full_clone': True})
assert settings.git_full_clone is True
assert settings.model_dump(mode='json')['git_full_clone'] is True