mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 20:44:36 +00:00
Coverage baseline raised from 13.65% to 26% (+12.35%) Gate raised from 12% to 18% Tasks completed: - COV-001: Command system unit tests (100% coverage) - COV-002: API service unit tests batch 1 (user/apikey/model/provider) - COV-003: Provider model manager unit tests - COV-004: Pipeline remaining stage tests (aggregator/cntfilter/longtext/msgtrun) - COV-005: Storage and utils coverage pass - COV-006: Gate ratchet 12%→15% - COV-007: Gate ratchet 15%→18% - COV-008: API service batch 2 (bot/pipeline/webhook/space/maintenance/mcp) - COV-009: Blocked - API controller circular import issue documented - COV-010: Plugin runtime unit tests (+0.08%) - COV-011: RAG and vector unit tests (+0.68%) - COV-012: Core boot and migration unit tests - COV-013: Provider requester logic unit tests (+0.62%) Key additions: - tests/utils/import_isolation.py: sys.modules isolation for circular imports - Provider requester mock tests: proved HTTP-dependent code can be tested locally - Vector filter utilities: 100% coverage on pure functions - API services: fake persistence pattern for unit testing Blocked issue COV-009 documented in langbot-test-plan/1.5/issues/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
608 lines
20 KiB
Python
608 lines
20 KiB
Python
"""
|
|
Unit tests for UserService.
|
|
|
|
Tests user management operations including:
|
|
- User initialization check
|
|
- Local user creation and authentication
|
|
- JWT token generation and verification
|
|
- Password management (reset, change, set)
|
|
- Space account management
|
|
|
|
Source: src/langbot/pkg/api/http/service/user.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, Mock
|
|
from types import SimpleNamespace
|
|
|
|
from langbot.pkg.api.http.service.user import UserService
|
|
from langbot.pkg.entity.persistence.user import User
|
|
from langbot.pkg.entity.errors.account import AccountEmailMismatchError
|
|
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
def _create_mock_user(
|
|
email: str = 'test@example.com',
|
|
password: str = 'hashed_password',
|
|
account_type: str = 'local',
|
|
space_account_uuid: str = None,
|
|
) -> Mock:
|
|
"""Helper to create mock User entity."""
|
|
user = Mock(spec=User)
|
|
user.user = email
|
|
user.password = password
|
|
user.account_type = account_type
|
|
user.space_account_uuid = space_account_uuid
|
|
return user
|
|
|
|
|
|
def _create_mock_result(items: list = None, first_item=None):
|
|
"""Create mock result object for persistence queries."""
|
|
result = Mock()
|
|
result.all = Mock(return_value=items or [])
|
|
result.first = Mock(return_value=first_item)
|
|
return result
|
|
|
|
|
|
class TestUserServiceIsInitialized:
|
|
"""Tests for is_initialized method."""
|
|
|
|
async def test_is_initialized_returns_true_when_users_exist(self):
|
|
"""Returns True when at least one user exists."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_user = _create_mock_user()
|
|
mock_result = _create_mock_result([mock_user])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.is_initialized()
|
|
|
|
# Verify
|
|
assert result is True
|
|
|
|
async def test_is_initialized_returns_false_when_no_users(self):
|
|
"""Returns False when no users exist."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.is_initialized()
|
|
|
|
# Verify
|
|
assert result is False
|
|
|
|
async def test_is_initialized_returns_false_on_none_result(self):
|
|
"""Returns False when result is None."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = Mock()
|
|
mock_result.all = Mock(return_value=None)
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.is_initialized()
|
|
|
|
# Verify
|
|
assert result is False
|
|
|
|
|
|
class TestUserServiceGetUserByEmail:
|
|
"""Tests for get_user_by_email method."""
|
|
|
|
async def test_get_user_by_email_found(self):
|
|
"""Returns user when found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_user = _create_mock_user(email='found@example.com')
|
|
mock_result = _create_mock_result([mock_user])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_user_by_email('found@example.com')
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.user == 'found@example.com'
|
|
|
|
async def test_get_user_by_email_not_found(self):
|
|
"""Returns None when user not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_user_by_email('notfound@example.com')
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
async def test_get_user_by_email_empty_string(self):
|
|
"""Handles empty email string."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_user_by_email('')
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
|
|
class TestUserServiceGetUserBySpaceAccountUuid:
|
|
"""Tests for get_user_by_space_account_uuid method."""
|
|
|
|
async def test_get_user_by_space_uuid_found(self):
|
|
"""Returns user when Space UUID found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_user = _create_mock_user(
|
|
email='space@example.com',
|
|
account_type='space',
|
|
space_account_uuid='space-uuid-123',
|
|
)
|
|
mock_result = _create_mock_result([mock_user])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_user_by_space_account_uuid('space-uuid-123')
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.space_account_uuid == 'space-uuid-123'
|
|
|
|
async def test_get_user_by_space_uuid_not_found(self):
|
|
"""Returns None when Space UUID not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_user_by_space_account_uuid('nonexistent-uuid')
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
|
|
class TestUserServiceAuthenticate:
|
|
"""Tests for authenticate method."""
|
|
|
|
async def test_authenticate_user_not_found_raises_error(self):
|
|
"""Raises ValueError when user not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='用户不存在'):
|
|
await service.authenticate('nonexistent@example.com', 'password')
|
|
|
|
async def test_authenticate_space_user_without_password_raises_error(self):
|
|
"""Raises ValueError for Space user without local password."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
# Space user has empty password
|
|
mock_user = _create_mock_user(
|
|
email='space@example.com',
|
|
password='', # Empty password for Space user
|
|
account_type='space',
|
|
)
|
|
mock_result = _create_mock_result([mock_user])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='请使用 Space 账户登录'):
|
|
await service.authenticate('space@example.com', 'password')
|
|
|
|
|
|
class TestUserServiceGenerateJwtToken:
|
|
"""Tests for generate_jwt_token method."""
|
|
|
|
async def test_generate_jwt_token_returns_valid_token(self):
|
|
"""Generates valid JWT token."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
token = await service.generate_jwt_token('test@example.com')
|
|
|
|
# Verify - JWT format (base64 encoded parts)
|
|
assert token is not None
|
|
assert len(token) > 0
|
|
parts = token.split('.')
|
|
assert len(parts) == 3 # JWT has 3 parts
|
|
|
|
async def test_generate_jwt_token_custom_expire(self):
|
|
"""Generates token with custom expiry."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
token = await service.generate_jwt_token('test@example.com')
|
|
|
|
# Verify
|
|
assert token is not None
|
|
|
|
|
|
class TestUserServiceVerifyJwtToken:
|
|
"""Tests for verify_jwt_token method."""
|
|
|
|
async def test_verify_jwt_token_valid(self):
|
|
"""Verifies valid JWT token and returns user email."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
|
|
|
service = UserService(ap)
|
|
|
|
# First generate a valid token
|
|
token = await service.generate_jwt_token('verify@example.com')
|
|
|
|
# Execute
|
|
user_email = await service.verify_jwt_token(token)
|
|
|
|
# Verify
|
|
assert user_email == 'verify@example.com'
|
|
|
|
async def test_verify_jwt_token_invalid_raises_error(self):
|
|
"""Raises error for invalid JWT token."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute & Verify - invalid token should raise JWT error
|
|
with pytest.raises(Exception): # jwt.DecodeError or similar
|
|
await service.verify_jwt_token('invalid.token.here')
|
|
|
|
|
|
class TestUserServiceResetPassword:
|
|
"""Tests for reset_password method."""
|
|
|
|
async def test_reset_password_updates_password(self):
|
|
"""Updates user password."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
await service.reset_password('test@example.com', 'new_password')
|
|
|
|
# Verify - execute_async was called with update
|
|
ap.persistence_mgr.execute_async.assert_called_once()
|
|
|
|
|
|
class TestUserServiceChangePassword:
|
|
"""Tests for change_password method."""
|
|
|
|
async def test_change_password_user_not_found_raises_error(self):
|
|
"""Raises ValueError when user not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock get_user_by_email to return None
|
|
service.get_user_by_email = AsyncMock(return_value=None)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='User not found'):
|
|
await service.change_password('nonexistent@example.com', 'current', 'new')
|
|
|
|
async def test_change_password_no_local_password_raises_error(self):
|
|
"""Raises ValueError when user has no local password set."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock user without password
|
|
mock_user = _create_mock_user(email='nopass@example.com', password=None)
|
|
service.get_user_by_email = AsyncMock(return_value=mock_user)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='No local password set'):
|
|
await service.change_password('nopass@example.com', 'current', 'new')
|
|
|
|
|
|
class TestUserServiceGetFirstUser:
|
|
"""Tests for get_first_user method."""
|
|
|
|
async def test_get_first_user_found(self):
|
|
"""Returns first user when exists."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_user = _create_mock_user(email='first@example.com')
|
|
mock_result = _create_mock_result([mock_user])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_first_user()
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.user == 'first@example.com'
|
|
|
|
async def test_get_first_user_not_found(self):
|
|
"""Returns None when no users exist."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = UserService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_first_user()
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
|
|
class TestUserServiceSetPassword:
|
|
"""Tests for set_password method."""
|
|
|
|
async def test_set_password_user_not_found_raises_error(self):
|
|
"""Raises ValueError when user not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock get_user_by_email to return None
|
|
service.get_user_by_email = AsyncMock(return_value=None)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='User not found'):
|
|
await service.set_password('nonexistent@example.com', 'new_password')
|
|
|
|
async def test_set_password_with_existing_password_requires_current(self):
|
|
"""Requires current password when user has existing password."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock user with existing password
|
|
mock_user = _create_mock_user(email='haspass@example.com', password='hashed_old_password')
|
|
service.get_user_by_email = AsyncMock(return_value=mock_user)
|
|
|
|
# Execute & Verify - should raise when no current_password provided
|
|
with pytest.raises(ValueError, match='Current password is required'):
|
|
await service.set_password('haspass@example.com', 'new_password')
|
|
|
|
|
|
class TestUserServiceCreateOrUpdateSpaceUser:
|
|
"""Tests for create_or_update_space_user method."""
|
|
|
|
async def test_create_or_update_existing_space_user(self):
|
|
"""Updates existing Space user tokens."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.provider_service = SimpleNamespace()
|
|
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock existing Space user
|
|
existing_user = _create_mock_user(
|
|
email='space@example.com',
|
|
account_type='space',
|
|
space_account_uuid='existing-space-uuid',
|
|
)
|
|
service.get_user_by_space_account_uuid = AsyncMock(return_value=existing_user)
|
|
service.get_user_by_email = AsyncMock(return_value=None)
|
|
service.is_initialized = AsyncMock(return_value=True)
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
|
|
# Execute
|
|
updated_user = await service.create_or_update_space_user(
|
|
space_account_uuid='existing-space-uuid',
|
|
email='space@example.com',
|
|
access_token='new_access_token',
|
|
refresh_token='new_refresh_token',
|
|
api_key='new_api_key',
|
|
expires_in=3600,
|
|
)
|
|
|
|
# Verify - update was called and user returned
|
|
ap.persistence_mgr.execute_async.assert_called()
|
|
assert updated_user.space_account_uuid == 'existing-space-uuid'
|
|
|
|
async def test_create_or_update_new_space_user_first_init(self):
|
|
"""Creates new Space user on first initialization."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.provider_service = SimpleNamespace()
|
|
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock new user to be returned after creation
|
|
new_user = _create_mock_user(
|
|
email='newspace@example.com',
|
|
account_type='space',
|
|
space_account_uuid='new-space-uuid',
|
|
)
|
|
|
|
# First call (line 138) returns None, second call (line 194) returns new_user
|
|
call_count = 0
|
|
async def mock_get_by_space_uuid(uuid):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1: # First check for existing user
|
|
return None
|
|
return new_user # After insert, return the new user
|
|
|
|
service.get_user_by_space_account_uuid = AsyncMock(side_effect=mock_get_by_space_uuid)
|
|
service.get_user_by_email = AsyncMock(return_value=None)
|
|
service.is_initialized = AsyncMock(return_value=False) # Not initialized
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
|
|
# Execute
|
|
result = await service.create_or_update_space_user(
|
|
space_account_uuid='new-space-uuid',
|
|
email='newspace@example.com',
|
|
access_token='access_token',
|
|
refresh_token='refresh_token',
|
|
api_key='api_key',
|
|
expires_in=3600,
|
|
)
|
|
|
|
# Verify
|
|
assert result.space_account_uuid == 'new-space-uuid'
|
|
|
|
async def test_create_or_update_space_user_already_initialized_raises_error(self):
|
|
"""Raises AccountEmailMismatchError when system already initialized and user not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.provider_service = SimpleNamespace()
|
|
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Mock system already initialized, no matching users
|
|
service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
|
|
service.get_user_by_email = AsyncMock(return_value=None)
|
|
service.is_initialized = AsyncMock(return_value=True) # Already initialized
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(AccountEmailMismatchError):
|
|
await service.create_or_update_space_user(
|
|
space_account_uuid='unknown-space-uuid',
|
|
email='unknown@example.com',
|
|
access_token='token',
|
|
refresh_token='refresh',
|
|
api_key='key',
|
|
expires_in=3600,
|
|
)
|
|
|
|
async def test_create_or_update_space_user_no_expiry(self):
|
|
"""Creates Space user without token expiry."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.provider_service = SimpleNamespace()
|
|
ap.provider_service.update_space_model_provider_api_keys = AsyncMock()
|
|
|
|
service = UserService(ap)
|
|
|
|
new_user = _create_mock_user(
|
|
email='noexpiry@example.com',
|
|
account_type='space',
|
|
space_account_uuid='noexpiry-uuid',
|
|
)
|
|
|
|
# First call (line 138) returns None, second call (line 194) returns new_user
|
|
call_count = 0
|
|
async def mock_get_by_space_uuid(uuid):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1: # First check for existing user
|
|
return None
|
|
return new_user # After insert, return the new user
|
|
|
|
service.get_user_by_space_account_uuid = AsyncMock(side_effect=mock_get_by_space_uuid)
|
|
service.get_user_by_email = AsyncMock(return_value=None)
|
|
service.is_initialized = AsyncMock(return_value=False)
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
|
|
# Execute with expires_in=0 (no expiry)
|
|
result = await service.create_or_update_space_user(
|
|
space_account_uuid='noexpiry-uuid',
|
|
email='noexpiry@example.com',
|
|
access_token='token',
|
|
refresh_token='refresh',
|
|
api_key='key',
|
|
expires_in=0, # No expiry
|
|
)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.space_account_uuid == 'noexpiry-uuid'
|
|
|
|
|
|
class TestUserServiceCreateUserLock:
|
|
"""Tests for create_user_lock attribute."""
|
|
|
|
def test_create_user_lock_initialized(self):
|
|
"""Verify create_user_lock is initialized as asyncio.Lock."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
|
|
service = UserService(ap)
|
|
|
|
# Verify lock exists
|
|
assert hasattr(service, '_create_user_lock')
|
|
assert service._create_user_lock is not None |