trustgraph/tests/unit/test_gateway/test_config_receiver.py
cybermaggedon 89be656990
Release/v1.2 (#457)
* Bump setup.py versions for 1.1

* PoC MCP server (#419)

* Very initial MCP server PoC for TrustGraph

* Put service on port 8000

* Add MCP container and packages to buildout

* Update docs for API/CLI changes in 1.0 (#421)

* Update some API basics for the 0.23/1.0 API change

* Add MCP container push (#425)

* Add command args to the MCP server (#426)

* Host and port parameters

* Added websocket arg

* More docs

* MCP client support (#427)

- MCP client service
- Tool request/response schema
- API gateway support for mcp-tool
- Message translation for tool request & response
- Make mcp-tool using configuration service for information
  about where the MCP services are.

* Feature/react call mcp (#428)

Key Features

  - MCP Tool Integration: Added core MCP tool support with ToolClientSpec and ToolClient classes
  - API Enhancement: New mcp_tool method for flow-specific tool invocation
  - CLI Tooling: New tg-invoke-mcp-tool command for testing MCP integration
  - React Agent Enhancement: Fixed and improved multi-tool invocation capabilities
  - Tool Management: Enhanced CLI for tool configuration and management

Changes

  - Added MCP tool invocation to API with flow-specific integration
  - Implemented ToolClientSpec and ToolClient for tool call handling
  - Updated agent-manager-react to invoke MCP tools with configurable types
  - Enhanced CLI with new commands and improved help text
  - Added comprehensive documentation for new CLI commands
  - Improved tool configuration management

Testing

  - Added tg-invoke-mcp-tool CLI command for isolated MCP integration testing
  - Enhanced agent capability to invoke multiple tools simultaneously

* Test suite executed from CI pipeline (#433)

* Test strategy & test cases

* Unit tests

* Integration tests

* Extending test coverage (#434)

* Contract tests

* Testing embeedings

* Agent unit tests

* Knowledge pipeline tests

* Turn on contract tests

* Increase storage test coverage (#435)

* Fixing storage and adding tests

* PR pipeline only runs quick tests

* Empty configuration is returned as empty list, previously was not in response (#436)

* Update config util to take files as well as command-line text (#437)

* Updated CLI invocation and config model for tools and mcp (#438)

* Updated CLI invocation and config model for tools and mcp

* CLI anomalies

* Tweaked the MCP tool implementation for new model

* Update agent implementation to match the new model

* Fix agent tools, now all tested

* Fixed integration tests

* Fix MCP delete tool params

* Update Python deps to 1.2

* Update to enable knowledge extraction using the agent framework (#439)

* Implement KG extraction agent (kg-extract-agent)

* Using ReAct framework (agent-manager-react)
 
* ReAct manager had an issue when emitting JSON, which conflicts which ReAct manager's own JSON messages, so refactored ReAct manager to use traditional ReAct messages, non-JSON structure.
 
* Minor refactor to take the prompt template client out of prompt-template so it can be more readily used by other modules. kg-extract-agent uses this framework.

* Migrate from setup.py to pyproject.toml (#440)

* Converted setup.py to pyproject.toml

* Modern package infrastructure as recommended by py docs

* Install missing build deps (#441)

* Install missing build deps (#442)

* Implement logging strategy (#444)

* Logging strategy and convert all prints() to logging invocations

* Fix/startup failure (#445)

* Fix loggin startup problems

* Fix logging startup problems (#446)

* Fix logging startup problems (#447)

* Fixed Mistral OCR to use current API (#448)

* Fixed Mistral OCR to use current API

* Added PDF decoder tests

* Fix Mistral OCR ident to be standard pdf-decoder (#450)

* Fix Mistral OCR ident to be standard pdf-decoder

* Correct test

* Schema structure refactor (#451)

* Write schema refactor spec

* Implemented schema refactor spec

* Structure data mvp (#452)

* Structured data tech spec

* Architecture principles

* New schemas

* Updated schemas and specs

* Object extractor

* Add .coveragerc

* New tests

* Cassandra object storage

* Trying to object extraction working, issues exist

* Validate librarian collection (#453)

* Fix token chunker, broken API invocation (#454)

* Fix token chunker, broken API invocation (#455)

* Knowledge load utility CLI (#456)

* Knowledge loader

* More tests
2025-08-18 20:56:09 +01:00

408 lines
No EOL
15 KiB
Python

"""
Tests for Gateway Config Receiver
"""
import pytest
import asyncio
import json
from unittest.mock import Mock, patch, Mock, MagicMock
import uuid
from trustgraph.gateway.config.receiver import ConfigReceiver
# Save the real method before patching
_real_config_loader = ConfigReceiver.config_loader
# Patch async methods at module level to prevent coroutine warnings
ConfigReceiver.config_loader = Mock()
class TestConfigReceiver:
"""Test cases for ConfigReceiver class"""
def test_config_receiver_initialization(self):
"""Test ConfigReceiver initialization"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
assert config_receiver.pulsar_client == mock_pulsar_client
assert config_receiver.flow_handlers == []
assert config_receiver.flows == {}
def test_add_handler(self):
"""Test adding flow handlers"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
handler1 = Mock()
handler2 = Mock()
config_receiver.add_handler(handler1)
config_receiver.add_handler(handler2)
assert len(config_receiver.flow_handlers) == 2
assert handler1 in config_receiver.flow_handlers
assert handler2 in config_receiver.flow_handlers
@pytest.mark.asyncio
async def test_on_config_with_new_flows(self):
"""Test on_config method with new flows"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Track calls manually instead of using AsyncMock
start_flow_calls = []
async def mock_start_flow(*args):
start_flow_calls.append(args)
config_receiver.start_flow = mock_start_flow
# Create mock message with flows
mock_msg = Mock()
mock_msg.value.return_value = Mock(
version="1.0",
config={
"flows": {
"flow1": '{"name": "test_flow_1", "steps": []}',
"flow2": '{"name": "test_flow_2", "steps": []}'
}
}
)
await config_receiver.on_config(mock_msg, None, None)
# Verify flows were added
assert "flow1" in config_receiver.flows
assert "flow2" in config_receiver.flows
assert config_receiver.flows["flow1"] == {"name": "test_flow_1", "steps": []}
assert config_receiver.flows["flow2"] == {"name": "test_flow_2", "steps": []}
# Verify start_flow was called for each new flow
assert len(start_flow_calls) == 2
assert ("flow1", {"name": "test_flow_1", "steps": []}) in start_flow_calls
assert ("flow2", {"name": "test_flow_2", "steps": []}) in start_flow_calls
@pytest.mark.asyncio
async def test_on_config_with_removed_flows(self):
"""Test on_config method with removed flows"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Pre-populate with existing flows
config_receiver.flows = {
"flow1": {"name": "test_flow_1", "steps": []},
"flow2": {"name": "test_flow_2", "steps": []}
}
# Track calls manually instead of using AsyncMock
stop_flow_calls = []
async def mock_stop_flow(*args):
stop_flow_calls.append(args)
config_receiver.stop_flow = mock_stop_flow
# Create mock message with only flow1 (flow2 removed)
mock_msg = Mock()
mock_msg.value.return_value = Mock(
version="1.0",
config={
"flows": {
"flow1": '{"name": "test_flow_1", "steps": []}'
}
}
)
await config_receiver.on_config(mock_msg, None, None)
# Verify flow2 was removed
assert "flow1" in config_receiver.flows
assert "flow2" not in config_receiver.flows
# Verify stop_flow was called for removed flow
assert len(stop_flow_calls) == 1
assert stop_flow_calls[0] == ("flow2", {"name": "test_flow_2", "steps": []})
@pytest.mark.asyncio
async def test_on_config_with_no_flows(self):
"""Test on_config method with no flows in config"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Mock the start_flow and stop_flow methods with async functions
async def mock_start_flow(*args):
pass
async def mock_stop_flow(*args):
pass
config_receiver.start_flow = mock_start_flow
config_receiver.stop_flow = mock_stop_flow
# Create mock message without flows
mock_msg = Mock()
mock_msg.value.return_value = Mock(
version="1.0",
config={}
)
await config_receiver.on_config(mock_msg, None, None)
# Verify no flows were added
assert config_receiver.flows == {}
# Since no flows were in the config, the flow methods shouldn't be called
# (We can't easily assert this with simple async functions, but the test
# passes if no exceptions are thrown)
@pytest.mark.asyncio
async def test_on_config_exception_handling(self):
"""Test on_config method handles exceptions gracefully"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Create mock message that will cause an exception
mock_msg = Mock()
mock_msg.value.side_effect = Exception("Test exception")
# This should not raise an exception
await config_receiver.on_config(mock_msg, None, None)
# Verify flows remain empty
assert config_receiver.flows == {}
@pytest.mark.asyncio
async def test_start_flow_with_handlers(self):
"""Test start_flow method with multiple handlers"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Add mock handlers
handler1 = Mock()
handler1.start_flow = Mock()
handler2 = Mock()
handler2.start_flow = Mock()
config_receiver.add_handler(handler1)
config_receiver.add_handler(handler2)
flow_data = {"name": "test_flow", "steps": []}
await config_receiver.start_flow("flow1", flow_data)
# Verify all handlers were called
handler1.start_flow.assert_called_once_with("flow1", flow_data)
handler2.start_flow.assert_called_once_with("flow1", flow_data)
@pytest.mark.asyncio
async def test_start_flow_with_handler_exception(self):
"""Test start_flow method handles handler exceptions"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Add mock handler that raises exception
handler = Mock()
handler.start_flow = Mock(side_effect=Exception("Handler error"))
config_receiver.add_handler(handler)
flow_data = {"name": "test_flow", "steps": []}
# This should not raise an exception
await config_receiver.start_flow("flow1", flow_data)
# Verify handler was called
handler.start_flow.assert_called_once_with("flow1", flow_data)
@pytest.mark.asyncio
async def test_stop_flow_with_handlers(self):
"""Test stop_flow method with multiple handlers"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Add mock handlers
handler1 = Mock()
handler1.stop_flow = Mock()
handler2 = Mock()
handler2.stop_flow = Mock()
config_receiver.add_handler(handler1)
config_receiver.add_handler(handler2)
flow_data = {"name": "test_flow", "steps": []}
await config_receiver.stop_flow("flow1", flow_data)
# Verify all handlers were called
handler1.stop_flow.assert_called_once_with("flow1", flow_data)
handler2.stop_flow.assert_called_once_with("flow1", flow_data)
@pytest.mark.asyncio
async def test_stop_flow_with_handler_exception(self):
"""Test stop_flow method handles handler exceptions"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Add mock handler that raises exception
handler = Mock()
handler.stop_flow = Mock(side_effect=Exception("Handler error"))
config_receiver.add_handler(handler)
flow_data = {"name": "test_flow", "steps": []}
# This should not raise an exception
await config_receiver.stop_flow("flow1", flow_data)
# Verify handler was called
handler.stop_flow.assert_called_once_with("flow1", flow_data)
@pytest.mark.asyncio
async def test_config_loader_creates_consumer(self):
"""Test config_loader method creates Pulsar consumer"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Temporarily restore the real config_loader for this test
config_receiver.config_loader = _real_config_loader.__get__(config_receiver)
# Mock Consumer class
with patch('trustgraph.gateway.config.receiver.Consumer') as mock_consumer_class, \
patch('uuid.uuid4') as mock_uuid:
mock_uuid.return_value = "test-uuid"
mock_consumer = Mock()
async def mock_start():
pass
mock_consumer.start = mock_start
mock_consumer_class.return_value = mock_consumer
# Create a task that will complete quickly
async def quick_task():
await config_receiver.config_loader()
# Run the task with a timeout to prevent hanging
try:
await asyncio.wait_for(quick_task(), timeout=0.1)
except asyncio.TimeoutError:
# This is expected since the method runs indefinitely
pass
# Verify Consumer was created with correct parameters
mock_consumer_class.assert_called_once()
call_args = mock_consumer_class.call_args
assert call_args[1]['client'] == mock_pulsar_client
assert call_args[1]['subscriber'] == "gateway-test-uuid"
assert call_args[1]['handler'] == config_receiver.on_config
assert call_args[1]['start_of_messages'] is True
@patch('asyncio.create_task')
@pytest.mark.asyncio
async def test_start_creates_config_loader_task(self, mock_create_task):
"""Test start method creates config loader task"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Mock create_task to avoid actually creating tasks with real coroutines
mock_task = Mock()
mock_create_task.return_value = mock_task
await config_receiver.start()
# Verify task was created
mock_create_task.assert_called_once()
# Verify the argument passed to create_task is a coroutine
call_args = mock_create_task.call_args[0]
assert len(call_args) == 1 # Should have one argument (the coroutine)
@pytest.mark.asyncio
async def test_on_config_mixed_flow_operations(self):
"""Test on_config with mixed add/remove operations"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Pre-populate with existing flows
config_receiver.flows = {
"flow1": {"name": "test_flow_1", "steps": []},
"flow2": {"name": "test_flow_2", "steps": []}
}
# Track calls manually instead of using Mock
start_flow_calls = []
stop_flow_calls = []
async def mock_start_flow(*args):
start_flow_calls.append(args)
async def mock_stop_flow(*args):
stop_flow_calls.append(args)
# Directly assign to avoid patch.object detecting async methods
original_start_flow = config_receiver.start_flow
original_stop_flow = config_receiver.stop_flow
config_receiver.start_flow = mock_start_flow
config_receiver.stop_flow = mock_stop_flow
try:
# Create mock message with flow1 removed and flow3 added
mock_msg = Mock()
mock_msg.value.return_value = Mock(
version="1.0",
config={
"flows": {
"flow2": '{"name": "test_flow_2", "steps": []}',
"flow3": '{"name": "test_flow_3", "steps": []}'
}
}
)
await config_receiver.on_config(mock_msg, None, None)
# Verify final state
assert "flow1" not in config_receiver.flows
assert "flow2" in config_receiver.flows
assert "flow3" in config_receiver.flows
# Verify operations
assert len(start_flow_calls) == 1
assert start_flow_calls[0] == ("flow3", {"name": "test_flow_3", "steps": []})
assert len(stop_flow_calls) == 1
assert stop_flow_calls[0] == ("flow1", {"name": "test_flow_1", "steps": []})
finally:
# Restore original methods
config_receiver.start_flow = original_start_flow
config_receiver.stop_flow = original_stop_flow
@pytest.mark.asyncio
async def test_on_config_invalid_json_flow_data(self):
"""Test on_config handles invalid JSON in flow data"""
mock_pulsar_client = Mock()
config_receiver = ConfigReceiver(mock_pulsar_client)
# Mock the start_flow method with an async function
async def mock_start_flow(*args):
pass
config_receiver.start_flow = mock_start_flow
# Create mock message with invalid JSON
mock_msg = Mock()
mock_msg.value.return_value = Mock(
version="1.0",
config={
"flows": {
"flow1": '{"invalid": json}', # Invalid JSON
"flow2": '{"name": "valid_flow", "steps": []}' # Valid JSON
}
}
)
# This should handle the exception gracefully
await config_receiver.on_config(mock_msg, None, None)
# The entire operation should fail due to JSON parsing error
# So no flows should be added
assert config_receiver.flows == {}