4a19d70af1
Lint with Ruff / ruff (push) Has been cancelled
MCP Server Tests / live-mcp-tests (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
Tests / database-integration-tests (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Server Tests / live-server-tests (push) Has been cancelled
Pyright Type Check / pyright (push) Has been cancelled
312 lines
11 KiB
Python
312 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for the MCP <-> graphiti-core parity wiring.
|
|
|
|
These tests exercise the pure helper functions and the queue-service argument
|
|
threading without requiring a live database or LLM. They run as part of the
|
|
default (non-integration) suite.
|
|
"""
|
|
|
|
import inspect
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from graphiti_core import Graphiti
|
|
from graphiti_core.edges import EntityEdge
|
|
from graphiti_core.nodes import EntityNode
|
|
from graphiti_core.search.search_filters import ComparisonOperator, SearchFilters
|
|
|
|
# Add the src directory to the path (mirrors the other unit tests)
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
|
|
|
|
from config.schema import ( # noqa: E402
|
|
EdgeTypeConfig,
|
|
EdgeTypeMapEntry,
|
|
EntityTypeConfig,
|
|
)
|
|
from models.edge_types import EDGE_TYPES # noqa: E402
|
|
from models.entity_types import ENTITY_TYPES # noqa: E402
|
|
from services.queue_service import QueueService # noqa: E402
|
|
from utils.type_config import ( # noqa: E402
|
|
build_edge_type_map,
|
|
build_edge_types,
|
|
build_entity_types,
|
|
build_fact_search_filters,
|
|
coerce_group_ids,
|
|
parse_reference_time,
|
|
)
|
|
|
|
|
|
class TestParseReferenceTime:
|
|
def test_none_returns_none(self):
|
|
assert parse_reference_time(None) is None
|
|
|
|
def test_naive_value_is_coerced_to_utc(self):
|
|
result = parse_reference_time('2025-01-15T10:30:00')
|
|
assert result is not None
|
|
assert result.tzinfo is timezone.utc
|
|
assert result == datetime(2025, 1, 15, 10, 30, tzinfo=timezone.utc)
|
|
|
|
def test_trailing_z_is_treated_as_utc(self):
|
|
result = parse_reference_time('2025-01-15T10:30:00Z')
|
|
assert result == datetime(2025, 1, 15, 10, 30, tzinfo=timezone.utc)
|
|
|
|
def test_explicit_offset_is_converted_to_utc(self):
|
|
result = parse_reference_time('2025-01-15T10:30:00+02:00')
|
|
assert result is not None
|
|
assert result.tzinfo is timezone.utc
|
|
assert result == datetime(2025, 1, 15, 8, 30, tzinfo=timezone.utc)
|
|
|
|
def test_invalid_string_raises_value_error(self):
|
|
with pytest.raises(ValueError):
|
|
parse_reference_time('not-a-timestamp')
|
|
|
|
|
|
class TestBuildEntityTypes:
|
|
def test_none_when_empty(self):
|
|
assert build_entity_types(None) is None
|
|
assert build_entity_types([]) is None
|
|
|
|
def test_registered_model_is_preferred(self):
|
|
cfg = [EntityTypeConfig(name='Preference', description='ignored description')]
|
|
result = build_entity_types(cfg)
|
|
assert result is not None
|
|
# The rich registered model, not a doc-only stub, must be used.
|
|
assert result['Preference'] is ENTITY_TYPES['Preference']
|
|
|
|
def test_unknown_name_falls_back_to_doc_only_model(self):
|
|
cfg = [EntityTypeConfig(name='Widget', description='A made-up type')]
|
|
result = build_entity_types(cfg)
|
|
assert result is not None
|
|
model = result['Widget']
|
|
assert model.__name__ == 'Widget'
|
|
assert model.__doc__ == 'A made-up type'
|
|
assert model not in ENTITY_TYPES.values()
|
|
|
|
|
|
class TestBuildEdgeTypes:
|
|
def test_none_when_empty(self):
|
|
assert build_edge_types(None) is None
|
|
assert build_edge_types([]) is None
|
|
|
|
def test_registered_model_is_preferred(self):
|
|
cfg = [EdgeTypeConfig(name='WorksFor', description='ignored')]
|
|
result = build_edge_types(cfg)
|
|
assert result is not None
|
|
assert result['WorksFor'] is EDGE_TYPES['WorksFor']
|
|
|
|
def test_unknown_name_falls_back_to_doc_only_model(self):
|
|
cfg = [EdgeTypeConfig(name='CustomEdge', description='custom relation')]
|
|
result = build_edge_types(cfg)
|
|
assert result is not None
|
|
assert result['CustomEdge'].__doc__ == 'custom relation'
|
|
|
|
|
|
class TestBuildEdgeTypeMap:
|
|
def test_none_when_empty(self):
|
|
assert build_edge_type_map(None) is None
|
|
assert build_edge_type_map([]) is None
|
|
|
|
def test_entries_become_tuple_keyed_map(self):
|
|
entries = [
|
|
EdgeTypeMapEntry(source='Person', target='Organization', edge_types=['WorksFor']),
|
|
EdgeTypeMapEntry(edge_types=['RelatesTo']), # defaults to Entity/Entity
|
|
]
|
|
result = build_edge_type_map(entries)
|
|
assert result == {
|
|
('Person', 'Organization'): ['WorksFor'],
|
|
('Entity', 'Entity'): ['RelatesTo'],
|
|
}
|
|
|
|
|
|
class TestBuildFactSearchFilters:
|
|
def test_none_when_no_criteria(self):
|
|
assert build_fact_search_filters() is None
|
|
|
|
def test_edge_types_only(self):
|
|
sf = build_fact_search_filters(edge_types=['WorksFor'])
|
|
assert isinstance(sf, SearchFilters)
|
|
assert sf.edge_types == ['WorksFor']
|
|
assert sf.valid_at is None
|
|
assert sf.invalid_at is None
|
|
|
|
def test_valid_at_range_is_and_group(self):
|
|
sf = build_fact_search_filters(
|
|
valid_at_after='2025-01-01T00:00:00Z',
|
|
valid_at_before='2025-02-01T00:00:00Z',
|
|
)
|
|
assert isinstance(sf, SearchFilters)
|
|
assert sf.valid_at is not None
|
|
# One OR group containing two AND-ed conditions (>= and <=).
|
|
assert len(sf.valid_at) == 1
|
|
operators = {cond.comparison_operator for cond in sf.valid_at[0]}
|
|
assert operators == {
|
|
ComparisonOperator.greater_than_equal,
|
|
ComparisonOperator.less_than_equal,
|
|
}
|
|
|
|
def test_invalid_date_raises_value_error(self):
|
|
with pytest.raises(ValueError):
|
|
build_fact_search_filters(valid_at_after='garbage')
|
|
|
|
|
|
class TestQueueServiceThreading:
|
|
"""The queue service must forward every parity param to Graphiti.add_episode."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_episode_forwards_all_params(self):
|
|
client = AsyncMock(spec=Graphiti)
|
|
service = QueueService()
|
|
await service.initialize(client)
|
|
|
|
ref_time = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
|
edge_types = {'WorksFor': EDGE_TYPES['WorksFor']}
|
|
edge_type_map = {('Entity', 'Entity'): ['WorksFor']}
|
|
|
|
await service.add_episode(
|
|
group_id='g1',
|
|
name='ep',
|
|
content='body',
|
|
source_description='desc',
|
|
episode_type='text',
|
|
entity_types={'Preference': ENTITY_TYPES['Preference']},
|
|
uuid='ep-uuid',
|
|
reference_time=ref_time,
|
|
edge_types=edge_types,
|
|
edge_type_map=edge_type_map,
|
|
excluded_entity_types=['Object'],
|
|
previous_episode_uuids=['prev-uuid'],
|
|
custom_extraction_instructions='extra',
|
|
update_communities=True,
|
|
saga='my-saga',
|
|
saga_previous_episode_uuid='saga-prev',
|
|
)
|
|
|
|
# The worker runs the queued coroutine in the background; wait for it.
|
|
await service._episode_queues['g1'].join()
|
|
|
|
client.add_episode.assert_awaited_once()
|
|
kwargs = client.add_episode.await_args.kwargs
|
|
assert kwargs['reference_time'] == ref_time
|
|
assert kwargs['edge_types'] == edge_types
|
|
assert kwargs['edge_type_map'] == edge_type_map
|
|
assert kwargs['excluded_entity_types'] == ['Object']
|
|
assert kwargs['previous_episode_uuids'] == ['prev-uuid']
|
|
assert kwargs['custom_extraction_instructions'] == 'extra'
|
|
assert kwargs['update_communities'] is True
|
|
assert kwargs['saga'] == 'my-saga'
|
|
assert kwargs['saga_previous_episode_uuid'] == 'saga-prev'
|
|
assert kwargs['uuid'] == 'ep-uuid'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_episode_defaults_reference_time_to_now(self):
|
|
client = AsyncMock(spec=Graphiti)
|
|
service = QueueService()
|
|
await service.initialize(client)
|
|
|
|
before = datetime.now(timezone.utc)
|
|
await service.add_episode(
|
|
group_id='g2',
|
|
name='ep',
|
|
content='body',
|
|
source_description='desc',
|
|
episode_type='text',
|
|
entity_types=None,
|
|
uuid=None,
|
|
)
|
|
await service._episode_queues['g2'].join()
|
|
after = datetime.now(timezone.utc)
|
|
|
|
kwargs = client.add_episode.await_args.kwargs
|
|
assert before <= kwargs['reference_time'] <= after
|
|
|
|
|
|
class TestCoreSignatureCompatibility:
|
|
"""Guard against drift between the params we send and graphiti-core's API."""
|
|
|
|
def test_queue_service_kwargs_are_accepted_by_add_episode(self):
|
|
params = set(inspect.signature(Graphiti.add_episode).parameters)
|
|
sent = {
|
|
'name',
|
|
'episode_body',
|
|
'source_description',
|
|
'source',
|
|
'group_id',
|
|
'reference_time',
|
|
'entity_types',
|
|
'edge_types',
|
|
'edge_type_map',
|
|
'excluded_entity_types',
|
|
'previous_episode_uuids',
|
|
'custom_extraction_instructions',
|
|
'update_communities',
|
|
'saga',
|
|
'saga_previous_episode_uuid',
|
|
'uuid',
|
|
}
|
|
assert sent <= params
|
|
|
|
def test_core_exposes_parity_methods(self):
|
|
# The new tools depend on these methods; guard against being pointed at a
|
|
# graphiti-core too old to support them (e.g. pre-0.29 lacks summarize_saga).
|
|
for method in (
|
|
'remove_episode',
|
|
'summarize_saga',
|
|
'build_communities',
|
|
'add_triplet',
|
|
'get_nodes_and_edges_by_episode',
|
|
):
|
|
assert hasattr(Graphiti, method), f'graphiti-core is missing {method}'
|
|
|
|
def test_triplet_objects_construct(self):
|
|
"""The shapes add_triplet builds must satisfy EntityNode/EntityEdge."""
|
|
now = datetime.now(timezone.utc)
|
|
source = EntityNode(uuid='s', name='Alice', group_id='g', created_at=now)
|
|
target = EntityNode(uuid='t', name='Acme', group_id='g', created_at=now)
|
|
edge = EntityEdge(
|
|
name='WORKS_FOR',
|
|
fact='Alice works for Acme',
|
|
group_id='g',
|
|
source_node_uuid=source.uuid,
|
|
target_node_uuid=target.uuid,
|
|
created_at=now,
|
|
)
|
|
assert edge.source_node_uuid == 's'
|
|
assert edge.target_node_uuid == 't'
|
|
|
|
|
|
class TestEntityTypeRegistration:
|
|
"""Configured entity types must be registerable with graphiti-core."""
|
|
|
|
def test_configured_entity_types_avoid_reserved_field_names(self):
|
|
# graphiti-core rejects custom entity-type fields that collide with
|
|
# EntityNode's own fields (e.g. 'name'); such a clash silently fails every
|
|
# episode ingest. Guard the registered models against reintroducing one.
|
|
from models.entity_types import ENTITY_TYPES
|
|
|
|
reserved = set(EntityNode.model_fields.keys())
|
|
for type_name, model in ENTITY_TYPES.items():
|
|
clashes = set(model.model_fields.keys()) & reserved
|
|
assert not clashes, (
|
|
f'entity type {type_name} uses reserved EntityNode field(s): {sorted(clashes)}'
|
|
)
|
|
|
|
|
|
class TestCoerceGroupIds:
|
|
"""Read tools accept a scalar group_id or a list (graphiti-core wants a list)."""
|
|
|
|
def test_scalar_string_becomes_one_element_list(self):
|
|
assert coerce_group_ids('g1') == ['g1']
|
|
|
|
def test_list_passes_through(self):
|
|
assert coerce_group_ids(['g1', 'g2']) == ['g1', 'g2']
|
|
|
|
def test_none_passes_through(self):
|
|
assert coerce_group_ids(None) is None
|
|
|
|
def test_blank_string_is_treated_as_omitted(self):
|
|
# A blank scalar falls back to the default group (not group '').
|
|
assert coerce_group_ids('') is None
|