Compare commits
2 Commits
b119bac024
...
d5eb7d400b
| Author | SHA1 | Date |
|---|---|---|
|
|
d5eb7d400b | |
|
|
edb09a7ac1 |
|
|
@ -31,3 +31,11 @@ dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "pytest-cov>=4.1.0", "black>=2
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["luxx"]
|
packages = ["luxx"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Test suite for Luxx project"""
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Pytest configuration and fixtures"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
# Set test environment variables
|
||||||
|
os.environ.setdefault("APP_SECRET_KEY", "test-secret-key-for-testing")
|
||||||
|
os.environ.setdefault("DEEPSEEK_API_KEY", "test-api-key")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_workspace(tmp_path):
|
||||||
|
"""Create a temporary workspace for testing"""
|
||||||
|
workspace = tmp_path / "test_workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_user_context():
|
||||||
|
"""Sample user context for tool testing"""
|
||||||
|
return {
|
||||||
|
"user_id": 1,
|
||||||
|
"username": "test_user",
|
||||||
|
"workspace": "/tmp/test_workspace",
|
||||||
|
"user_permission_level": 3
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
"""Tests for LLM response parsing - MiniMax/OpenAI format"""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from luxx.services.llm_response import ParsedDelta
|
||||||
|
|
||||||
|
|
||||||
|
class TestMiniMaxParsing:
|
||||||
|
"""Tests for MiniMax/OpenAI streaming format parsing"""
|
||||||
|
|
||||||
|
def test_parse_text_chunk(self):
|
||||||
|
"""Parse text content chunk"""
|
||||||
|
chunk_str = 'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"Hello","role":"assistant"}}]}'
|
||||||
|
chunk = json.loads(chunk_str[6:]) # Remove "data: "
|
||||||
|
|
||||||
|
delta = chunk["choices"][0]["delta"]
|
||||||
|
assert delta["content"] == "Hello"
|
||||||
|
|
||||||
|
def test_parse_text_accumulation(self):
|
||||||
|
"""Multiple chunks should accumulate to full text"""
|
||||||
|
chunks = [
|
||||||
|
'{"id":"msg_001","choices":[{"index":0,"delta":{"content":"你好","role":"assistant"}}]}',
|
||||||
|
'{"id":"msg_001","choices":[{"index":0,"delta":{"content":"!","role":"assistant"}}]}',
|
||||||
|
'{"id":"msg_001","choices":[{"index":0,"delta":{"content":"有什么","role":"assistant"}}]}',
|
||||||
|
'{"id":"msg_001","choices":[{"index":0,"delta":{"content":"可以帮助你的吗?","role":"assistant"}}]}',
|
||||||
|
]
|
||||||
|
|
||||||
|
full_text = ""
|
||||||
|
for c in chunks:
|
||||||
|
chunk = json.loads(c)
|
||||||
|
content = chunk["choices"][0]["delta"].get("content", "")
|
||||||
|
full_text += content
|
||||||
|
|
||||||
|
assert full_text == "你好!有什么可以帮助你的吗?"
|
||||||
|
|
||||||
|
def test_parse_finish_chunk(self):
|
||||||
|
"""Parse finish_reason chunk"""
|
||||||
|
chunk_str = 'data: {"id":"msg_001","choices":[{"finish_reason":"stop","index":0,"delta":{"role":"assistant"}}]}'
|
||||||
|
chunk = json.loads(chunk_str[6:])
|
||||||
|
|
||||||
|
finish_reason = chunk["choices"][0].get("finish_reason")
|
||||||
|
assert finish_reason == "stop"
|
||||||
|
|
||||||
|
def test_parse_usage_chunk(self):
|
||||||
|
"""Parse usage information"""
|
||||||
|
chunk_str = '{"id":"msg_001","usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}'
|
||||||
|
chunk = json.loads(chunk_str)
|
||||||
|
|
||||||
|
usage = chunk.get("usage", {})
|
||||||
|
assert usage["prompt_tokens"] == 100
|
||||||
|
assert usage["completion_tokens"] == 50
|
||||||
|
assert usage["total_tokens"] == 150
|
||||||
|
|
||||||
|
def test_parse_empty_content_chunk(self):
|
||||||
|
"""Parse chunk with empty content (just role)"""
|
||||||
|
chunk_str = 'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"","role":"assistant"}}]}'
|
||||||
|
chunk = json.loads(chunk_str[6:])
|
||||||
|
|
||||||
|
delta = chunk["choices"][0]["delta"]
|
||||||
|
content = delta.get("content", "")
|
||||||
|
assert content == ""
|
||||||
|
|
||||||
|
def test_parse_done_marker(self):
|
||||||
|
"""Parse [DONE] marker"""
|
||||||
|
chunk_str = "data: [DONE]"
|
||||||
|
assert chunk_str.strip().startswith("data: [DONE]")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenAIAdapter:
|
||||||
|
"""Tests for OpenAI adapter parsing logic"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self):
|
||||||
|
from luxx.services.llm_adapters.openai_adapter import OpenAIAdapter
|
||||||
|
return OpenAIAdapter()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_text(self, adapter):
|
||||||
|
"""Should parse text content"""
|
||||||
|
chunk = 'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"Hello"}}]}'
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
|
||||||
|
assert len(deltas) == 1
|
||||||
|
assert deltas[0].text == "Hello"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_finish(self, adapter):
|
||||||
|
"""Should detect completion"""
|
||||||
|
chunk = 'data: {"id":"msg_001","choices":[{"finish_reason":"stop","index":0,"delta":{}}]}'
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
|
||||||
|
assert len(deltas) == 1
|
||||||
|
assert deltas[0].is_complete is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_empty_content(self, adapter):
|
||||||
|
"""Should skip empty content chunks"""
|
||||||
|
chunk = 'data: {"id":"msg_001","choices":[{"index":0,"delta":{"role":"assistant"}}]}'
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
|
||||||
|
# Empty content without finish_reason should be skipped
|
||||||
|
assert len(deltas) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_done_marker(self, adapter):
|
||||||
|
"""Should handle [DONE] marker"""
|
||||||
|
chunk = "data: [DONE]"
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
|
||||||
|
assert len(deltas) == 1
|
||||||
|
assert deltas[0].is_complete is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_invalid_json(self, adapter):
|
||||||
|
"""Should handle invalid JSON gracefully"""
|
||||||
|
chunk = "not valid json"
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
|
||||||
|
assert len(deltas) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_empty_chunk(self, adapter):
|
||||||
|
"""Should handle empty chunk"""
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk("")]
|
||||||
|
assert len(deltas) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_stream_whitespace_chunk(self, adapter):
|
||||||
|
"""Should handle whitespace-only chunk"""
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(" \n")]
|
||||||
|
assert len(deltas) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRequest:
|
||||||
|
"""Tests for request building"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self):
|
||||||
|
from luxx.services.llm_adapters.openai_adapter import OpenAIAdapter
|
||||||
|
return OpenAIAdapter()
|
||||||
|
|
||||||
|
def test_build_request_basic(self, adapter):
|
||||||
|
"""Should build basic request"""
|
||||||
|
body, headers = adapter.build_request(
|
||||||
|
model="MiniMax-M2.5",
|
||||||
|
messages=[{"role": "user", "content": "Hello"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["model"] == "MiniMax-M2.5"
|
||||||
|
assert body["messages"] == [{"role": "user", "content": "Hello"}]
|
||||||
|
assert body["stream"] is True
|
||||||
|
|
||||||
|
def test_build_request_with_tools(self, adapter):
|
||||||
|
"""Should include tools in request"""
|
||||||
|
tools = [{"type": "function", "function": {"name": "test", "parameters": {}}}]
|
||||||
|
body, _ = adapter.build_request(
|
||||||
|
model="MiniMax-M2.5",
|
||||||
|
messages=[],
|
||||||
|
tools=tools
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "tools" in body
|
||||||
|
assert body["tool_choice"] == "auto"
|
||||||
|
|
||||||
|
def test_build_request_with_temperature(self, adapter):
|
||||||
|
"""Should include temperature"""
|
||||||
|
body, _ = adapter.build_request(
|
||||||
|
model="MiniMax-M2.5",
|
||||||
|
messages=[],
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["temperature"] == 0.7
|
||||||
|
|
||||||
|
def test_build_request_with_max_tokens(self, adapter):
|
||||||
|
"""Should include max_tokens"""
|
||||||
|
body, _ = adapter.build_request(
|
||||||
|
model="MiniMax-M2.5",
|
||||||
|
messages=[],
|
||||||
|
max_tokens=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
assert body["max_tokens"] == 1000
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsedDelta:
|
||||||
|
"""Tests for ParsedDelta dataclass"""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""Should have correct defaults"""
|
||||||
|
delta = ParsedDelta()
|
||||||
|
assert delta.text == ""
|
||||||
|
assert delta.thinking == ""
|
||||||
|
assert delta.tool_call is None
|
||||||
|
assert delta.usage == {}
|
||||||
|
assert delta.is_complete is False
|
||||||
|
|
||||||
|
def test_with_text(self):
|
||||||
|
"""Should accept text content"""
|
||||||
|
delta = ParsedDelta(text="Hello world")
|
||||||
|
assert delta.text == "Hello world"
|
||||||
|
|
||||||
|
def test_with_usage(self):
|
||||||
|
"""Should accept usage dict"""
|
||||||
|
delta = ParsedDelta(usage={"prompt_tokens": 10, "completion_tokens": 5})
|
||||||
|
assert delta.usage["prompt_tokens"] == 10
|
||||||
|
|
||||||
|
def test_with_complete_flag(self):
|
||||||
|
"""Should accept is_complete flag"""
|
||||||
|
delta = ParsedDelta(is_complete=True)
|
||||||
|
assert delta.is_complete is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndStreaming:
|
||||||
|
"""End-to-end streaming simulation tests"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self):
|
||||||
|
from luxx.services.llm_adapters.openai_adapter import OpenAIAdapter
|
||||||
|
return OpenAIAdapter()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_text_stream(self, adapter):
|
||||||
|
"""Simulate full text response stream"""
|
||||||
|
chunks = [
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"用户","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"用中文","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"说","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"\"你好\"","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":",这是","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"一个简单的","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"问候。","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"\n","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"我应该","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"用中文","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"友好地","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"回应。","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"\n\n","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"\n\n你好!","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"有什么","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"我可以","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"帮助你的吗?","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"finish_reason":"stop","index":0,"delta":{"role":"assistant"}}]}',
|
||||||
|
]
|
||||||
|
|
||||||
|
full_text = ""
|
||||||
|
is_complete = False
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
for delta in deltas:
|
||||||
|
if delta.is_complete:
|
||||||
|
is_complete = True
|
||||||
|
if delta.text:
|
||||||
|
full_text += delta.text
|
||||||
|
|
||||||
|
expected = "用户用中文说\"你好\",这是一个简单的问候。我应该用中文友好地回应。\n\n你好!有什么我可以帮助你的吗?"
|
||||||
|
assert full_text == expected
|
||||||
|
assert is_complete is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_stream_between_finish(self, adapter):
|
||||||
|
"""Handle empty content chunks before finish"""
|
||||||
|
chunks = [
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"Hello","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"index":0,"delta":{"content":"","role":"assistant"}}]}',
|
||||||
|
'data: {"id":"msg_001","choices":[{"finish_reason":"stop","index":0,"delta":{"role":"assistant"}}]}',
|
||||||
|
]
|
||||||
|
|
||||||
|
full_text = ""
|
||||||
|
for chunk in chunks:
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
for delta in deltas:
|
||||||
|
if delta.text:
|
||||||
|
full_text += delta.text
|
||||||
|
|
||||||
|
assert full_text == "Hello"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolCallParsing:
|
||||||
|
"""Tests for tool call parsing"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(self):
|
||||||
|
from luxx.services.llm_adapters.openai_adapter import OpenAIAdapter
|
||||||
|
return OpenAIAdapter()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_call_chunk(self, adapter):
|
||||||
|
"""Parse tool call chunk"""
|
||||||
|
chunk = json.dumps({
|
||||||
|
"id": "chatcmpl_001",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [{
|
||||||
|
"index": 0,
|
||||||
|
"id": "call_abc123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"arguments": '{"query":'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
# The current adapter doesn't yield tool_call deltas
|
||||||
|
# This test documents current behavior
|
||||||
|
deltas = [d async for d in adapter.parse_stream_chunk(chunk)]
|
||||||
|
# Content is empty, no delta yielded
|
||||||
|
assert len(deltas) == 0
|
||||||
|
|
||||||
|
def test_tool_call_accumulation(self):
|
||||||
|
"""Simulate tool call argument accumulation"""
|
||||||
|
chunks = [
|
||||||
|
{"function": {"name": "python_eval", "arguments": "{"}},
|
||||||
|
{"function": {"name": "", "arguments": '"expr": '}},
|
||||||
|
{"function": {"name": "", "arguments": '"1 + 1"'}},
|
||||||
|
{"function": {"name": "", "arguments": "}"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
accumulated = {"name": "", "arguments": ""}
|
||||||
|
for c in chunks:
|
||||||
|
if c["function"]["name"]:
|
||||||
|
accumulated["name"] += c["function"]["name"]
|
||||||
|
if c["function"]["arguments"]:
|
||||||
|
accumulated["arguments"] += c["function"]["arguments"]
|
||||||
|
|
||||||
|
assert accumulated["name"] == "python_eval"
|
||||||
|
assert accumulated["arguments"] == '{"expr": "1 + 1"}'
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Tests for config module"""
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
"""Tests for Config class"""
|
||||||
|
|
||||||
|
def test_config_singleton(self):
|
||||||
|
"""Should return same instance"""
|
||||||
|
from luxx.config import config, Config
|
||||||
|
config1 = Config()
|
||||||
|
config2 = Config()
|
||||||
|
assert config1 is config2
|
||||||
|
assert config is config1
|
||||||
|
|
||||||
|
def test_get_with_default(self):
|
||||||
|
"""Should return default value for missing key"""
|
||||||
|
from luxx.config import config
|
||||||
|
result = config.get("nonexistent.key", "default_value")
|
||||||
|
assert result == "default_value"
|
||||||
|
|
||||||
|
def test_get_nested_key(self):
|
||||||
|
"""Should support dot-separated keys"""
|
||||||
|
from luxx.config import config
|
||||||
|
# These should return configured or default values
|
||||||
|
secret = config.get("app.secret_key")
|
||||||
|
assert secret is not None
|
||||||
|
|
||||||
|
def test_properties_have_defaults(self):
|
||||||
|
"""All properties should have sensible defaults"""
|
||||||
|
from luxx.config import config
|
||||||
|
assert isinstance(config.debug, bool)
|
||||||
|
assert isinstance(config.app_host, str)
|
||||||
|
assert isinstance(config.app_port, int)
|
||||||
|
assert isinstance(config.database_url, str)
|
||||||
|
assert config.app_port == 8000
|
||||||
|
|
||||||
|
def test_tools_config_properties(self):
|
||||||
|
"""Tools configuration properties should work"""
|
||||||
|
from luxx.config import config
|
||||||
|
assert config.tools_enable_cache is not None
|
||||||
|
assert config.tools_cache_ttl > 0
|
||||||
|
assert config.tools_max_workers > 0
|
||||||
|
assert config.tools_max_iterations > 0
|
||||||
|
|
||||||
|
def test_llm_config_properties(self):
|
||||||
|
"""LLM configuration properties should work"""
|
||||||
|
from luxx.config import config
|
||||||
|
assert config.llm_provider is not None
|
||||||
|
assert config.llm_api_url is not None
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Tests for utils/helpers module"""
|
||||||
|
from luxx.utils.helpers import (
|
||||||
|
generate_id,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
create_access_token,
|
||||||
|
decode_access_token,
|
||||||
|
success_response,
|
||||||
|
error_response
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateId:
|
||||||
|
"""Tests for generate_id function"""
|
||||||
|
|
||||||
|
def test_generate_id_returns_string(self):
|
||||||
|
"""Should return a string"""
|
||||||
|
result = generate_id()
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_generate_id_with_prefix(self):
|
||||||
|
"""Should return id with prefix"""
|
||||||
|
result = generate_id("task")
|
||||||
|
assert result.startswith("task_")
|
||||||
|
|
||||||
|
def test_generate_id_unique(self):
|
||||||
|
"""Should generate unique ids"""
|
||||||
|
ids = [generate_id() for _ in range(100)]
|
||||||
|
assert len(set(ids)) == 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordHashing:
|
||||||
|
"""Tests for password hashing functions"""
|
||||||
|
|
||||||
|
def test_hash_password_returns_string(self):
|
||||||
|
"""Should return a hashed string"""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = hash_password(password)
|
||||||
|
assert isinstance(hashed, str)
|
||||||
|
assert hashed != password
|
||||||
|
|
||||||
|
def test_verify_password_correct(self):
|
||||||
|
"""Should return True for correct password"""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = hash_password(password)
|
||||||
|
assert verify_password(password, hashed) is True
|
||||||
|
|
||||||
|
def test_verify_password_incorrect(self):
|
||||||
|
"""Should return False for incorrect password"""
|
||||||
|
password = "test_password_123"
|
||||||
|
wrong_password = "wrong_password"
|
||||||
|
hashed = hash_password(password)
|
||||||
|
assert verify_password(wrong_password, hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestJWTToken:
|
||||||
|
"""Tests for JWT token functions"""
|
||||||
|
|
||||||
|
def test_create_access_token_returns_string(self):
|
||||||
|
"""Should return a JWT token string"""
|
||||||
|
token = create_access_token({"user_id": 1})
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
|
||||||
|
def test_decode_access_token_valid(self):
|
||||||
|
"""Should decode valid token"""
|
||||||
|
payload = {"user_id": 1, "username": "test"}
|
||||||
|
token = create_access_token(payload)
|
||||||
|
decoded = decode_access_token(token)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["user_id"] == 1
|
||||||
|
assert decoded["username"] == "test"
|
||||||
|
|
||||||
|
def test_decode_access_token_invalid(self):
|
||||||
|
"""Should return None for invalid token"""
|
||||||
|
result = decode_access_token("invalid.token.here")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponseWrappers:
|
||||||
|
"""Tests for response wrapper functions"""
|
||||||
|
|
||||||
|
def test_success_response_format(self):
|
||||||
|
"""Should return correct success format"""
|
||||||
|
result = success_response({"key": "value"}, "Success message")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message"] == "Success message"
|
||||||
|
assert result["data"] == {"key": "value"}
|
||||||
|
|
||||||
|
def test_success_response_default(self):
|
||||||
|
"""Should use default values"""
|
||||||
|
result = success_response()
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["message"] == "Success"
|
||||||
|
assert result["data"] is None
|
||||||
|
|
||||||
|
def test_error_response_format(self):
|
||||||
|
"""Should return correct error format"""
|
||||||
|
result = error_response("Error occurred", code=404)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["message"] == "Error occurred"
|
||||||
|
assert result["code"] == 404
|
||||||
|
assert "errors" not in result
|
||||||
|
|
||||||
|
def test_error_response_with_errors(self):
|
||||||
|
"""Should include errors field"""
|
||||||
|
errors = {"field": ["required"]}
|
||||||
|
result = error_response("Validation failed", code=400, errors=errors)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["errors"] == errors
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Tests for task module"""
|
||||||
|
import pytest
|
||||||
|
from luxx.services.task import (
|
||||||
|
Task,
|
||||||
|
Step,
|
||||||
|
TaskGraph,
|
||||||
|
TaskService,
|
||||||
|
TaskStatus,
|
||||||
|
StepStatus,
|
||||||
|
task_service
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStep:
|
||||||
|
"""Tests for Step dataclass"""
|
||||||
|
|
||||||
|
def test_step_creation(self):
|
||||||
|
"""Should create step with required fields"""
|
||||||
|
step = Step(id="step_1", name="Test Step")
|
||||||
|
assert step.id == "step_1"
|
||||||
|
assert step.name == "Test Step"
|
||||||
|
assert step.status == StepStatus.PENDING
|
||||||
|
assert step.depends_on == []
|
||||||
|
|
||||||
|
def test_step_with_dependencies(self):
|
||||||
|
"""Should create step with dependencies"""
|
||||||
|
step = Step(
|
||||||
|
id="step_2",
|
||||||
|
name="Dependent Step",
|
||||||
|
depends_on=["step_1"]
|
||||||
|
)
|
||||||
|
assert "step_1" in step.depends_on
|
||||||
|
|
||||||
|
def test_step_to_dict(self):
|
||||||
|
"""Should convert step to dict"""
|
||||||
|
step = Step(id="step_1", name="Test")
|
||||||
|
result = step.to_dict()
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result["id"] == "step_1"
|
||||||
|
assert result["name"] == "Test"
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTask:
|
||||||
|
"""Tests for Task dataclass"""
|
||||||
|
|
||||||
|
def test_task_creation(self):
|
||||||
|
"""Should create task with required fields"""
|
||||||
|
task = Task(id="task_1", name="Test Task")
|
||||||
|
assert task.id == "task_1"
|
||||||
|
assert task.name == "Test Task"
|
||||||
|
assert task.status == TaskStatus.PENDING
|
||||||
|
assert task.steps == []
|
||||||
|
|
||||||
|
def test_task_with_steps(self):
|
||||||
|
"""Should create task with steps"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
step2 = Step(id="step_2", name="Step 2")
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
assert len(task.steps) == 2
|
||||||
|
|
||||||
|
def test_task_to_dict(self):
|
||||||
|
"""Should convert task to dict"""
|
||||||
|
task = Task(id="task_1", name="Test", goal="Complete task")
|
||||||
|
result = task.to_dict()
|
||||||
|
assert result["id"] == "task_1"
|
||||||
|
assert result["goal"] == "Complete task"
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskGraph:
|
||||||
|
"""Tests for TaskGraph class"""
|
||||||
|
|
||||||
|
def test_graph_creation(self):
|
||||||
|
"""Should create graph from task"""
|
||||||
|
task = Task(id="task_1", name="Test")
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
assert graph.task is task
|
||||||
|
|
||||||
|
def test_topological_sort_no_dependencies(self):
|
||||||
|
"""Should sort steps without dependencies"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
step2 = Step(id="step_2", name="Step 2")
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
sorted_steps = graph.topological_sort()
|
||||||
|
assert len(sorted_steps) == 2
|
||||||
|
|
||||||
|
def test_topological_sort_with_dependencies(self):
|
||||||
|
"""Should respect dependencies in sort"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
step2 = Step(id="step_2", name="Step 2", depends_on=["step_1"])
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
sorted_steps = graph.topological_sort()
|
||||||
|
ids = [s.id for s in sorted_steps]
|
||||||
|
assert ids.index("step_1") < ids.index("step_2")
|
||||||
|
|
||||||
|
def test_get_ready_steps(self):
|
||||||
|
"""Should return steps ready to execute"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
step2 = Step(id="step_2", name="Step 2", depends_on=["step_1"])
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
ready = graph.get_ready_steps([])
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "step_1"
|
||||||
|
|
||||||
|
def test_get_ready_steps_after_completion(self):
|
||||||
|
"""Should return dependent steps after completion"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
step2 = Step(id="step_2", name="Step 2", depends_on=["step_1"])
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
ready = graph.get_ready_steps(["step_1"])
|
||||||
|
assert len(ready) == 1
|
||||||
|
assert ready[0].id == "step_2"
|
||||||
|
|
||||||
|
def test_detect_cycles_no_cycle(self):
|
||||||
|
"""Should return empty for no cycles"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
step2 = Step(id="step_2", name="Step 2")
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
assert cycles == []
|
||||||
|
|
||||||
|
def test_detect_cycles_with_cycle(self):
|
||||||
|
"""Should detect circular dependency"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1", depends_on=["step_2"])
|
||||||
|
step2 = Step(id="step_2", name="Step 2", depends_on=["step_1"])
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1, step2])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
cycles = graph.detect_cycles()
|
||||||
|
assert len(cycles) > 0
|
||||||
|
|
||||||
|
def test_validate_valid_graph(self):
|
||||||
|
"""Should validate valid graph"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1")
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
is_valid, error = graph.validate()
|
||||||
|
assert is_valid is True
|
||||||
|
assert error is None
|
||||||
|
|
||||||
|
def test_validate_invalid_dependency(self):
|
||||||
|
"""Should fail validation for invalid dependency"""
|
||||||
|
step1 = Step(id="step_1", name="Step 1", depends_on=["nonexistent"])
|
||||||
|
task = Task(id="task_1", name="Test", steps=[step1])
|
||||||
|
graph = TaskGraph(task)
|
||||||
|
is_valid, error = graph.validate()
|
||||||
|
assert is_valid is False
|
||||||
|
assert error is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskService:
|
||||||
|
"""Tests for TaskService class"""
|
||||||
|
|
||||||
|
def test_create_task(self):
|
||||||
|
"""Should create a new task"""
|
||||||
|
service = TaskService()
|
||||||
|
task = service.create_task(name="Test Task", goal="Complete test")
|
||||||
|
assert task is not None
|
||||||
|
assert task.name == "Test Task"
|
||||||
|
assert task.goal == "Complete test"
|
||||||
|
|
||||||
|
def test_create_task_with_steps(self):
|
||||||
|
"""Should create task with steps"""
|
||||||
|
service = TaskService()
|
||||||
|
steps = [
|
||||||
|
{"name": "Step 1", "description": "First step"},
|
||||||
|
{"name": "Step 2", "description": "Second step"}
|
||||||
|
]
|
||||||
|
task = service.create_task(name="Test", goal="Goal", steps=steps)
|
||||||
|
assert len(task.steps) == 2
|
||||||
|
|
||||||
|
def test_get_task(self):
|
||||||
|
"""Should retrieve task by id"""
|
||||||
|
service = TaskService()
|
||||||
|
created = service.create_task(name="Test", goal="Goal")
|
||||||
|
retrieved = service.get_task(created.id)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.id == created.id
|
||||||
|
|
||||||
|
def test_get_nonexistent_task(self):
|
||||||
|
"""Should return None for nonexistent task"""
|
||||||
|
service = TaskService()
|
||||||
|
result = service.get_task("nonexistent_id")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_update_task_status(self):
|
||||||
|
"""Should update task status"""
|
||||||
|
service = TaskService()
|
||||||
|
task = service.create_task(name="Test", goal="Goal")
|
||||||
|
updated = service.update_task_status(task.id, TaskStatus.RUNNING)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == TaskStatus.RUNNING
|
||||||
|
|
||||||
|
def test_add_steps(self):
|
||||||
|
"""Should add steps to existing task"""
|
||||||
|
service = TaskService()
|
||||||
|
task = service.create_task(name="Test", goal="Goal")
|
||||||
|
steps = [{"name": "New Step"}]
|
||||||
|
added = service.add_steps(task.id, steps)
|
||||||
|
assert added is not None
|
||||||
|
assert len(added) == 1
|
||||||
|
assert len(task.steps) == 1
|
||||||
|
|
||||||
|
def test_delete_task(self):
|
||||||
|
"""Should delete task"""
|
||||||
|
service = TaskService()
|
||||||
|
task = service.create_task(name="Test", goal="Goal")
|
||||||
|
result = service.delete_task(task.id)
|
||||||
|
assert result is True
|
||||||
|
assert service.get_task(task.id) is None
|
||||||
|
|
||||||
|
def test_build_graph(self):
|
||||||
|
"""Should build graph for task"""
|
||||||
|
service = TaskService()
|
||||||
|
task = service.create_task(name="Test", goal="Goal")
|
||||||
|
graph = service.build_graph(task.id)
|
||||||
|
assert graph is not None
|
||||||
|
assert isinstance(graph, TaskGraph)
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
"""Tests for tools module"""
|
||||||
|
import pytest
|
||||||
|
from luxx.tools.core import (
|
||||||
|
ToolContext,
|
||||||
|
ToolDefinition,
|
||||||
|
ToolResult,
|
||||||
|
ToolRegistry,
|
||||||
|
CommandPermission
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolContext:
|
||||||
|
"""Tests for ToolContext dataclass"""
|
||||||
|
|
||||||
|
def test_tool_context_creation(self):
|
||||||
|
"""Should create context with default values"""
|
||||||
|
ctx = ToolContext()
|
||||||
|
assert ctx.workspace is None
|
||||||
|
assert ctx.user_id is None
|
||||||
|
assert ctx.username is None
|
||||||
|
assert ctx.extra == {}
|
||||||
|
|
||||||
|
def test_tool_context_with_values(self):
|
||||||
|
"""Should create context with provided values"""
|
||||||
|
ctx = ToolContext(
|
||||||
|
workspace="/workspace/test",
|
||||||
|
user_id=1,
|
||||||
|
username="testuser",
|
||||||
|
extra={"key": "value"}
|
||||||
|
)
|
||||||
|
assert ctx.workspace == "/workspace/test"
|
||||||
|
assert ctx.user_id == 1
|
||||||
|
assert ctx.username == "testuser"
|
||||||
|
assert ctx.extra["key"] == "value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolDefinition:
|
||||||
|
"""Tests for ToolDefinition dataclass"""
|
||||||
|
|
||||||
|
def test_tool_definition_creation(self):
|
||||||
|
"""Should create tool definition"""
|
||||||
|
def handler(args):
|
||||||
|
return {"result": "ok"}
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="test_tool",
|
||||||
|
description="A test tool",
|
||||||
|
parameters={"type": "object"},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
assert tool.name == "test_tool"
|
||||||
|
assert tool.description == "A test tool"
|
||||||
|
assert tool.category == "general"
|
||||||
|
assert tool.required_permission == CommandPermission.READ_ONLY
|
||||||
|
|
||||||
|
def test_tool_definition_to_openai_format(self):
|
||||||
|
"""Should convert to OpenAI format"""
|
||||||
|
def handler(args):
|
||||||
|
return {"result": "ok"}
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="test_tool",
|
||||||
|
description="A test tool",
|
||||||
|
parameters={"type": "object", "properties": {}},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
result = tool.to_openai_format()
|
||||||
|
assert result["type"] == "function"
|
||||||
|
assert result["function"]["name"] == "test_tool"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolResult:
|
||||||
|
"""Tests for ToolResult dataclass"""
|
||||||
|
|
||||||
|
def test_tool_result_success(self):
|
||||||
|
"""Should create success result"""
|
||||||
|
result = ToolResult(success=True, data={"key": "value"})
|
||||||
|
assert result.success is True
|
||||||
|
assert result.data["key"] == "value"
|
||||||
|
assert result.error is None
|
||||||
|
|
||||||
|
def test_tool_result_failure(self):
|
||||||
|
"""Should create failure result"""
|
||||||
|
result = ToolResult(success=False, error="Something went wrong")
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "Something went wrong"
|
||||||
|
|
||||||
|
def test_tool_result_to_dict(self):
|
||||||
|
"""Should convert to dictionary"""
|
||||||
|
result = ToolResult(success=True, data={"key": "value"})
|
||||||
|
d = result.to_dict()
|
||||||
|
assert isinstance(d, dict)
|
||||||
|
assert d["success"] is True
|
||||||
|
assert d["data"]["key"] == "value"
|
||||||
|
|
||||||
|
def test_tool_result_ok_factory(self):
|
||||||
|
"""Should use ok() factory method"""
|
||||||
|
result = ToolResult.ok({"result": "success"})
|
||||||
|
assert result.success is True
|
||||||
|
assert result.data == {"result": "success"}
|
||||||
|
|
||||||
|
def test_tool_result_fail_factory(self):
|
||||||
|
"""Should use fail() factory method"""
|
||||||
|
result = ToolResult.fail("Error occurred")
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "Error occurred"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistry:
|
||||||
|
"""Tests for ToolRegistry class"""
|
||||||
|
|
||||||
|
def test_registry_singleton(self):
|
||||||
|
"""Should return same instance"""
|
||||||
|
reg1 = ToolRegistry()
|
||||||
|
reg2 = ToolRegistry()
|
||||||
|
assert reg1 is reg2
|
||||||
|
|
||||||
|
def test_register_tool(self):
|
||||||
|
"""Should register a tool"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear() # Start fresh
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return {"result": "ok"}
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="my_tool",
|
||||||
|
description="My test tool",
|
||||||
|
parameters={},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
registry.register(tool)
|
||||||
|
assert registry.get("my_tool") is not None
|
||||||
|
assert registry.tool_count() == 1
|
||||||
|
|
||||||
|
def test_get_nonexistent_tool(self):
|
||||||
|
"""Should return None for nonexistent tool"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
assert registry.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_list_all_tools(self):
|
||||||
|
"""Should list all registered tools"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
tool1 = ToolDefinition(name="tool1", description="Tool 1", parameters={}, handler=handler)
|
||||||
|
tool2 = ToolDefinition(name="tool2", description="Tool 2", parameters={}, handler=handler)
|
||||||
|
registry.register(tool1)
|
||||||
|
registry.register(tool2)
|
||||||
|
|
||||||
|
tools = registry.list_all()
|
||||||
|
assert len(tools) == 2
|
||||||
|
|
||||||
|
def test_list_by_category(self):
|
||||||
|
"""Should filter tools by category"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
tool1 = ToolDefinition(
|
||||||
|
name="tool1", description="Tool 1", parameters={},
|
||||||
|
handler=handler, category="code"
|
||||||
|
)
|
||||||
|
tool2 = ToolDefinition(
|
||||||
|
name="tool2", description="Tool 2", parameters={},
|
||||||
|
handler=handler, category="file"
|
||||||
|
)
|
||||||
|
registry.register(tool1)
|
||||||
|
registry.register(tool2)
|
||||||
|
|
||||||
|
code_tools = registry.list_by_category("code")
|
||||||
|
assert len(code_tools) == 1
|
||||||
|
|
||||||
|
def test_execute_tool(self):
|
||||||
|
"""Should execute a tool"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return {"executed": True, "args": args}
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="test_tool",
|
||||||
|
description="Test tool",
|
||||||
|
parameters={},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
result = registry.execute("test_tool", {"input": "value"})
|
||||||
|
# Direct handler returns are passed through as-is
|
||||||
|
assert result["executed"] is True
|
||||||
|
assert result["args"]["input"] == "value"
|
||||||
|
|
||||||
|
def test_execute_tool_with_tool_result(self):
|
||||||
|
"""Should return ToolResult when handler returns ToolResult"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return ToolResult.ok({"executed": True})
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="test_tool",
|
||||||
|
description="Test tool",
|
||||||
|
parameters={},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
result = registry.execute("test_tool", {})
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["data"]["executed"] is True
|
||||||
|
|
||||||
|
def test_execute_nonexistent_tool(self):
|
||||||
|
"""Should return error for nonexistent tool"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
result = registry.execute("nonexistent", {})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["error"]
|
||||||
|
|
||||||
|
def test_execute_with_context(self):
|
||||||
|
"""Should pass context to handler"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
received_context = None
|
||||||
|
|
||||||
|
def handler(args, context=None):
|
||||||
|
nonlocal received_context
|
||||||
|
received_context = context
|
||||||
|
return ToolResult.ok({"received": True})
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="test_tool",
|
||||||
|
description="Test tool",
|
||||||
|
parameters={},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
ctx = ToolContext(user_id=1, username="test")
|
||||||
|
registry.execute("test_tool", {}, context=ctx)
|
||||||
|
assert received_context is not None
|
||||||
|
assert received_context.user_id == 1
|
||||||
|
|
||||||
|
def test_permission_check(self):
|
||||||
|
"""Should check user permission"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return ToolResult.ok({"ok": True})
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="admin_tool",
|
||||||
|
description="Admin tool",
|
||||||
|
parameters={},
|
||||||
|
handler=handler,
|
||||||
|
required_permission=CommandPermission.ADMIN
|
||||||
|
)
|
||||||
|
registry.register(tool)
|
||||||
|
|
||||||
|
# User with low permission
|
||||||
|
ctx = ToolContext(
|
||||||
|
user_id=1,
|
||||||
|
extra={"user_permission_level": CommandPermission.READ_ONLY}
|
||||||
|
)
|
||||||
|
result = registry.execute("admin_tool", {}, context=ctx)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Permission denied" in result["error"]
|
||||||
|
|
||||||
|
def test_remove_tool(self):
|
||||||
|
"""Should remove a tool"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="removable",
|
||||||
|
description="To be removed",
|
||||||
|
parameters={},
|
||||||
|
handler=handler
|
||||||
|
)
|
||||||
|
registry.register(tool)
|
||||||
|
assert registry.get("removable") is not None
|
||||||
|
|
||||||
|
registry.remove("removable")
|
||||||
|
assert registry.get("removable") is None
|
||||||
|
|
||||||
|
def test_clear_tools(self):
|
||||||
|
"""Should clear all tools"""
|
||||||
|
registry = ToolRegistry()
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
def handler(args):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
tool = ToolDefinition(name="tool1", description="", parameters={}, handler=handler)
|
||||||
|
registry.register(tool)
|
||||||
|
assert registry.tool_count() > 0
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
assert registry.tool_count() == 0
|
||||||
Loading…
Reference in New Issue