2025-12-04 17:38:57 +00:00
|
|
|
"""
|
|
|
|
|
Unit tests for TrustGraph Python API client library
|
|
|
|
|
|
|
|
|
|
These tests use mocks and do not require a running server.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from unittest.mock import Mock, patch, MagicMock, call
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
from trustgraph.api import (
|
|
|
|
|
Api,
|
|
|
|
|
Triple,
|
|
|
|
|
AgentThought,
|
|
|
|
|
AgentObservation,
|
|
|
|
|
AgentAnswer,
|
|
|
|
|
RAGChunk,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestApiInstantiation:
|
|
|
|
|
"""Test Api class instantiation and configuration"""
|
|
|
|
|
|
|
|
|
|
def test_api_instantiation_defaults(self):
|
|
|
|
|
"""Test Api with default parameters"""
|
|
|
|
|
api = Api()
|
|
|
|
|
assert api.url == "http://localhost:8088/api/v1/"
|
|
|
|
|
assert api.timeout == 60
|
|
|
|
|
assert api.token is None
|
|
|
|
|
|
|
|
|
|
def test_api_instantiation_with_url(self):
|
|
|
|
|
"""Test Api with custom URL"""
|
|
|
|
|
api = Api(url="http://test-server:9000/")
|
|
|
|
|
assert api.url == "http://test-server:9000/api/v1/"
|
|
|
|
|
|
|
|
|
|
def test_api_instantiation_with_url_trailing_slash(self):
|
|
|
|
|
"""Test Api adds trailing slash if missing"""
|
|
|
|
|
api = Api(url="http://test-server:9000")
|
|
|
|
|
assert api.url == "http://test-server:9000/api/v1/"
|
|
|
|
|
|
|
|
|
|
def test_api_instantiation_with_token(self):
|
|
|
|
|
"""Test Api with authentication token"""
|
|
|
|
|
api = Api(token="test-token-123")
|
|
|
|
|
assert api.token == "test-token-123"
|
|
|
|
|
|
|
|
|
|
def test_api_instantiation_with_timeout(self):
|
|
|
|
|
"""Test Api with custom timeout"""
|
|
|
|
|
api = Api(timeout=120)
|
|
|
|
|
assert api.timeout == 120
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestApiLazyInitialization:
|
|
|
|
|
"""Test lazy initialization of client components"""
|
|
|
|
|
|
|
|
|
|
def test_socket_client_lazy_init(self):
|
|
|
|
|
"""Test socket client is created on first access"""
|
|
|
|
|
api = Api(url="http://test/", token="token")
|
|
|
|
|
|
|
|
|
|
assert api._socket_client is None
|
|
|
|
|
socket = api.socket()
|
|
|
|
|
assert api._socket_client is not None
|
|
|
|
|
assert socket is api._socket_client
|
|
|
|
|
|
|
|
|
|
# Second access returns same instance
|
|
|
|
|
socket2 = api.socket()
|
|
|
|
|
assert socket2 is socket
|
|
|
|
|
|
|
|
|
|
def test_bulk_client_lazy_init(self):
|
|
|
|
|
"""Test bulk client is created on first access"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
|
|
|
|
|
assert api._bulk_client is None
|
|
|
|
|
bulk = api.bulk()
|
|
|
|
|
assert api._bulk_client is not None
|
|
|
|
|
|
|
|
|
|
def test_async_flow_lazy_init(self):
|
|
|
|
|
"""Test async flow is created on first access"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
|
|
|
|
|
assert api._async_flow is None
|
|
|
|
|
async_flow = api.async_flow()
|
|
|
|
|
assert api._async_flow is not None
|
|
|
|
|
|
|
|
|
|
def test_metrics_lazy_init(self):
|
|
|
|
|
"""Test metrics client is created on first access"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
|
|
|
|
|
assert api._metrics is None
|
|
|
|
|
metrics = api.metrics()
|
|
|
|
|
assert api._metrics is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestApiContextManager:
|
|
|
|
|
"""Test context manager functionality"""
|
|
|
|
|
|
|
|
|
|
def test_sync_context_manager(self):
|
|
|
|
|
"""Test synchronous context manager"""
|
|
|
|
|
with Api(url="http://test/") as api:
|
|
|
|
|
assert api is not None
|
|
|
|
|
assert isinstance(api, Api)
|
|
|
|
|
# Should exit cleanly
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_async_context_manager(self):
|
|
|
|
|
"""Test asynchronous context manager"""
|
|
|
|
|
async with Api(url="http://test/") as api:
|
|
|
|
|
assert api is not None
|
|
|
|
|
assert isinstance(api, Api)
|
|
|
|
|
# Should exit cleanly
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFlowClient:
|
|
|
|
|
"""Test Flow client functionality"""
|
|
|
|
|
|
|
|
|
|
@patch('requests.post')
|
|
|
|
|
def test_flow_list(self, mock_post):
|
|
|
|
|
"""Test listing flows"""
|
|
|
|
|
mock_post.return_value.status_code = 200
|
|
|
|
|
mock_post.return_value.json.return_value = {"flow-ids": ["flow1", "flow2"]}
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
flows = api.flow().list()
|
|
|
|
|
|
|
|
|
|
assert flows == ["flow1", "flow2"]
|
|
|
|
|
assert mock_post.called
|
|
|
|
|
|
|
|
|
|
@patch('requests.post')
|
|
|
|
|
def test_flow_list_with_token(self, mock_post):
|
|
|
|
|
"""Test flow listing includes auth token"""
|
|
|
|
|
mock_post.return_value.status_code = 200
|
|
|
|
|
mock_post.return_value.json.return_value = {"flow-ids": []}
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/", token="my-token")
|
|
|
|
|
api.flow().list()
|
|
|
|
|
|
|
|
|
|
# Verify Authorization header was set
|
|
|
|
|
call_args = mock_post.call_args
|
|
|
|
|
headers = call_args[1]['headers'] if 'headers' in call_args[1] else {}
|
|
|
|
|
assert 'Authorization' in headers
|
|
|
|
|
assert headers['Authorization'] == 'Bearer my-token'
|
|
|
|
|
|
|
|
|
|
@patch('requests.post')
|
|
|
|
|
def test_flow_get(self, mock_post):
|
|
|
|
|
"""Test getting flow definition"""
|
|
|
|
|
flow_def = {"name": "test-flow", "description": "Test"}
|
|
|
|
|
mock_post.return_value.status_code = 200
|
|
|
|
|
mock_post.return_value.json.return_value = {"flow": json.dumps(flow_def)}
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
result = api.flow().get("test-flow")
|
|
|
|
|
|
|
|
|
|
assert result == flow_def
|
|
|
|
|
|
|
|
|
|
def test_flow_instance_creation(self):
|
|
|
|
|
"""Test creating flow instance"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
flow_instance = api.flow().id("my-flow")
|
|
|
|
|
|
|
|
|
|
assert flow_instance is not None
|
|
|
|
|
assert flow_instance.id == "my-flow"
|
|
|
|
|
|
|
|
|
|
def test_flow_instance_has_methods(self):
|
|
|
|
|
"""Test flow instance has expected methods"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
flow_instance = api.flow().id("my-flow")
|
|
|
|
|
|
|
|
|
|
expected_methods = [
|
|
|
|
|
'text_completion', 'agent', 'graph_rag', 'document_rag',
|
|
|
|
|
'graph_embeddings_query', 'embeddings', 'prompt',
|
2026-02-23 15:56:29 +00:00
|
|
|
'triples_query', 'rows_query'
|
2025-12-04 17:38:57 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for method in expected_methods:
|
|
|
|
|
assert hasattr(flow_instance, method), f"Missing method: {method}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSocketClient:
|
|
|
|
|
"""Test WebSocket client functionality"""
|
|
|
|
|
|
|
|
|
|
def test_socket_client_url_conversion_http(self):
|
|
|
|
|
"""Test HTTP URL converted to WebSocket"""
|
|
|
|
|
api = Api(url="http://test-server:8088/")
|
|
|
|
|
socket = api.socket()
|
|
|
|
|
|
|
|
|
|
assert socket.url.startswith("ws://")
|
|
|
|
|
assert "test-server" in socket.url
|
|
|
|
|
|
|
|
|
|
def test_socket_client_url_conversion_https(self):
|
|
|
|
|
"""Test HTTPS URL converted to secure WebSocket"""
|
|
|
|
|
api = Api(url="https://test-server:8088/")
|
|
|
|
|
socket = api.socket()
|
|
|
|
|
|
|
|
|
|
assert socket.url.startswith("wss://")
|
|
|
|
|
|
|
|
|
|
def test_socket_client_token_passed(self):
|
|
|
|
|
"""Test token is passed to socket client"""
|
|
|
|
|
api = Api(url="http://test/", token="socket-token")
|
|
|
|
|
socket = api.socket()
|
|
|
|
|
|
|
|
|
|
assert socket.token == "socket-token"
|
|
|
|
|
|
|
|
|
|
def test_socket_flow_instance(self):
|
|
|
|
|
"""Test creating socket flow instance"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
socket = api.socket()
|
|
|
|
|
flow_instance = socket.flow("test-flow")
|
|
|
|
|
|
|
|
|
|
assert flow_instance is not None
|
|
|
|
|
assert flow_instance.flow_id == "test-flow"
|
|
|
|
|
|
|
|
|
|
def test_socket_flow_has_methods(self):
|
|
|
|
|
"""Test socket flow instance has expected methods"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
flow_instance = api.socket().flow("test-flow")
|
|
|
|
|
|
|
|
|
|
expected_methods = [
|
|
|
|
|
'agent', 'text_completion', 'graph_rag', 'document_rag',
|
|
|
|
|
'prompt', 'graph_embeddings_query', 'embeddings',
|
2026-02-23 15:56:29 +00:00
|
|
|
'triples_query', 'rows_query', 'mcp_tool'
|
2025-12-04 17:38:57 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for method in expected_methods:
|
|
|
|
|
assert hasattr(flow_instance, method), f"Missing method: {method}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBulkClient:
|
|
|
|
|
"""Test bulk operations client"""
|
|
|
|
|
|
|
|
|
|
def test_bulk_client_url_conversion(self):
|
|
|
|
|
"""Test bulk client uses WebSocket URL"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
bulk = api.bulk()
|
|
|
|
|
|
|
|
|
|
assert bulk.url.startswith("ws://")
|
|
|
|
|
|
|
|
|
|
def test_bulk_client_has_import_methods(self):
|
|
|
|
|
"""Test bulk client has import methods"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
bulk = api.bulk()
|
|
|
|
|
|
|
|
|
|
import_methods = [
|
|
|
|
|
'import_triples',
|
|
|
|
|
'import_graph_embeddings',
|
|
|
|
|
'import_document_embeddings',
|
|
|
|
|
'import_entity_contexts',
|
2026-02-23 15:56:29 +00:00
|
|
|
'import_rows'
|
2025-12-04 17:38:57 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for method in import_methods:
|
|
|
|
|
assert hasattr(bulk, method), f"Missing method: {method}"
|
|
|
|
|
|
|
|
|
|
def test_bulk_client_has_export_methods(self):
|
|
|
|
|
"""Test bulk client has export methods"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
bulk = api.bulk()
|
|
|
|
|
|
|
|
|
|
export_methods = [
|
|
|
|
|
'export_triples',
|
|
|
|
|
'export_graph_embeddings',
|
|
|
|
|
'export_document_embeddings',
|
|
|
|
|
'export_entity_contexts'
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for method in export_methods:
|
|
|
|
|
assert hasattr(bulk, method), f"Missing method: {method}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMetricsClient:
|
|
|
|
|
"""Test metrics client"""
|
|
|
|
|
|
|
|
|
|
@patch('requests.get')
|
|
|
|
|
def test_metrics_get(self, mock_get):
|
|
|
|
|
"""Test getting metrics"""
|
|
|
|
|
mock_get.return_value.status_code = 200
|
|
|
|
|
mock_get.return_value.text = "# HELP metric_name\nmetric_name 42"
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
metrics_text = api.metrics().get()
|
|
|
|
|
|
|
|
|
|
assert "metric_name" in metrics_text
|
|
|
|
|
assert mock_get.called
|
|
|
|
|
|
|
|
|
|
@patch('requests.get')
|
|
|
|
|
def test_metrics_with_token(self, mock_get):
|
|
|
|
|
"""Test metrics request includes token"""
|
|
|
|
|
mock_get.return_value.status_code = 200
|
|
|
|
|
mock_get.return_value.text = "metrics"
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/", token="metrics-token")
|
|
|
|
|
api.metrics().get()
|
|
|
|
|
|
|
|
|
|
# Verify token in headers
|
|
|
|
|
call_args = mock_get.call_args
|
|
|
|
|
headers = call_args[1].get('headers', {})
|
|
|
|
|
assert 'Authorization' in headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStreamingTypes:
|
|
|
|
|
"""Test streaming chunk types"""
|
|
|
|
|
|
|
|
|
|
def test_agent_thought_creation(self):
|
|
|
|
|
"""Test creating AgentThought chunk"""
|
|
|
|
|
chunk = AgentThought(content="thinking...", end_of_message=False)
|
|
|
|
|
|
|
|
|
|
assert chunk.content == "thinking..."
|
|
|
|
|
assert chunk.end_of_message is False
|
|
|
|
|
assert chunk.chunk_type == "thought"
|
|
|
|
|
|
|
|
|
|
def test_agent_observation_creation(self):
|
|
|
|
|
"""Test creating AgentObservation chunk"""
|
|
|
|
|
chunk = AgentObservation(content="observing...", end_of_message=False)
|
|
|
|
|
|
|
|
|
|
assert chunk.content == "observing..."
|
|
|
|
|
assert chunk.chunk_type == "observation"
|
|
|
|
|
|
|
|
|
|
def test_agent_answer_creation(self):
|
|
|
|
|
"""Test creating AgentAnswer chunk"""
|
|
|
|
|
chunk = AgentAnswer(
|
|
|
|
|
content="answer",
|
|
|
|
|
end_of_message=True,
|
|
|
|
|
end_of_dialog=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert chunk.content == "answer"
|
|
|
|
|
assert chunk.end_of_message is True
|
|
|
|
|
assert chunk.end_of_dialog is True
|
|
|
|
|
assert chunk.chunk_type == "final-answer"
|
|
|
|
|
|
|
|
|
|
def test_rag_chunk_creation(self):
|
|
|
|
|
"""Test creating RAGChunk"""
|
|
|
|
|
chunk = RAGChunk(
|
|
|
|
|
content="response chunk",
|
|
|
|
|
end_of_stream=False,
|
|
|
|
|
error=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert chunk.content == "response chunk"
|
|
|
|
|
assert chunk.end_of_stream is False
|
|
|
|
|
assert chunk.error is None
|
|
|
|
|
|
|
|
|
|
def test_rag_chunk_with_error(self):
|
|
|
|
|
"""Test RAGChunk with error"""
|
|
|
|
|
error_dict = {"type": "error", "message": "failed"}
|
|
|
|
|
chunk = RAGChunk(
|
|
|
|
|
content="",
|
|
|
|
|
end_of_stream=True,
|
|
|
|
|
error=error_dict
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert chunk.error == error_dict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTripleType:
|
|
|
|
|
"""Test Triple data structure"""
|
|
|
|
|
|
|
|
|
|
def test_triple_creation(self):
|
|
|
|
|
"""Test creating Triple"""
|
|
|
|
|
triple = Triple(s="subject", p="predicate", o="object")
|
|
|
|
|
|
|
|
|
|
assert triple.s == "subject"
|
|
|
|
|
assert triple.p == "predicate"
|
|
|
|
|
assert triple.o == "object"
|
|
|
|
|
|
|
|
|
|
def test_triple_with_uris(self):
|
|
|
|
|
"""Test Triple with URI values"""
|
|
|
|
|
triple = Triple(
|
|
|
|
|
s="http://example.org/entity1",
|
|
|
|
|
p="http://example.org/relation",
|
|
|
|
|
o="http://example.org/entity2"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert triple.s.startswith("http://")
|
|
|
|
|
assert triple.p.startswith("http://")
|
|
|
|
|
assert triple.o.startswith("http://")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAsyncClients:
|
|
|
|
|
"""Test async client availability"""
|
|
|
|
|
|
|
|
|
|
def test_async_flow_creation(self):
|
|
|
|
|
"""Test creating async flow client"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
async_flow = api.async_flow()
|
|
|
|
|
|
|
|
|
|
assert async_flow is not None
|
|
|
|
|
|
|
|
|
|
def test_async_socket_creation(self):
|
|
|
|
|
"""Test creating async socket client"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
async_socket = api.async_socket()
|
|
|
|
|
|
|
|
|
|
assert async_socket is not None
|
|
|
|
|
assert async_socket.url.startswith("ws://")
|
|
|
|
|
|
|
|
|
|
def test_async_bulk_creation(self):
|
|
|
|
|
"""Test creating async bulk client"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
async_bulk = api.async_bulk()
|
|
|
|
|
|
|
|
|
|
assert async_bulk is not None
|
|
|
|
|
|
|
|
|
|
def test_async_metrics_creation(self):
|
|
|
|
|
"""Test creating async metrics client"""
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
async_metrics = api.async_metrics()
|
|
|
|
|
|
|
|
|
|
assert async_metrics is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestErrorHandling:
|
|
|
|
|
"""Test error handling"""
|
|
|
|
|
|
|
|
|
|
@patch('requests.post')
|
|
|
|
|
def test_protocol_exception_on_non_200(self, mock_post):
|
|
|
|
|
"""Test ProtocolException raised on non-200 status"""
|
|
|
|
|
from trustgraph.api.exceptions import ProtocolException
|
|
|
|
|
|
|
|
|
|
mock_post.return_value.status_code = 500
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ProtocolException):
|
|
|
|
|
api.flow().list()
|
|
|
|
|
|
|
|
|
|
@patch('requests.post')
|
|
|
|
|
def test_application_exception_on_error_response(self, mock_post):
|
|
|
|
|
"""Test ApplicationException on error in response"""
|
|
|
|
|
from trustgraph.api.exceptions import ApplicationException
|
|
|
|
|
|
|
|
|
|
mock_post.return_value.status_code = 200
|
|
|
|
|
mock_post.return_value.json.return_value = {
|
|
|
|
|
"error": {
|
|
|
|
|
"type": "ValidationError",
|
|
|
|
|
"message": "Invalid input"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api = Api(url="http://test/")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ApplicationException):
|
|
|
|
|
api.flow().list()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Run tests with: pytest tests/unit/test_python_api_client.py -v
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
pytest.main([__file__, "-v"])
|