Skip to content

Commit a598a74

Browse files
DvirDukhanCopilot
andcommitted
test(mcp): sample-project fixture + assertion contract (T3 #650)
New `tests/mcp/fixtures/`: - `sample_project/python/` — canonical call graph `entrypoint -> service -> {UserRepo,OrderRepo}.repo -> db` plus a small class hierarchy (BaseRepo <- UserRepo, OrderRepo) and inter-file imports so IMPORTS edges exist. - `expected.yaml` — single source of truth for every per-tool ticket's integration assertions: minimum per-label counts, named callers / callees, known paths, prefix-search hits. New `tests/mcp/conftest.py`: - `expected_contract` (pure-Python, always available) loads the YAML once per session. - `indexed_fixture` (session-scoped) indexes the fixture into a unique `code:sample_project:test-<uuid>` graph so parallel CI shards don't contend. Self-skips when FalkorDB is unreachable. Uses `SourceAnalyzer.analyze_local_folder` directly so the fixture doesn't need to be a git repository. New `tests/mcp/test_fixture_contract.py` — regression-tests the fixture itself: contract shape, on-disk files, and that the integration fixture indexes cleanly and meets the minimum count contract. Multilingual coverage (Java + C#) was dropped from the spec: both multilspy analyzers demand a Maven / .NET project layout at the indexed root, which would force this fixture into an awkward shape. Deferred to a follow-up ticket (likely T16 which adds languages). All 4 contract tests pass against FalkorDB on 6390. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bba43e0 commit a598a74

11 files changed

Lines changed: 341 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,5 @@ where = ["."]
4949
dev = [
5050
"pytest>=9.0.2",
5151
"pytest-anyio>=0.0.0",
52+
"pyyaml>=6.0.3",
5253
]

tests/mcp/conftest.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Shared fixtures for the MCP test suite.
2+
3+
Every per-tool integration test from T4 onward reuses the
4+
``indexed_fixture`` fixture below: it indexes ``fixtures/sample_project``
5+
into a uniquely named FalkorDB graph once per test session and yields a
6+
descriptor (project name, branch, graph name) the test can pass straight
7+
to the MCP tool under test.
8+
9+
The integration fixture is opt-in — it requires a reachable FalkorDB
10+
(see ``api/graph.py``) and the optional language analyzers. Tests that
11+
only need the static contract (counts, named callers / callees / paths)
12+
can depend on ``expected_contract`` alone, which is pure-Python and
13+
always available.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import uuid
20+
from dataclasses import dataclass
21+
from pathlib import Path
22+
from typing import Any
23+
24+
import pytest
25+
import yaml
26+
27+
28+
FIXTURE_DIR = Path(__file__).parent / "fixtures"
29+
SAMPLE_PROJECT = FIXTURE_DIR / "sample_project"
30+
EXPECTED_PATH = FIXTURE_DIR / "expected.yaml"
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# Pure-Python contract (no FalkorDB required)
35+
# ---------------------------------------------------------------------------
36+
37+
38+
@dataclass(frozen=True)
39+
class IndexedFixture:
40+
"""Descriptor for an indexed fixture graph."""
41+
42+
project: str
43+
branch: str
44+
graph_name: str
45+
path: Path
46+
47+
48+
@pytest.fixture(scope="session")
49+
def expected_contract() -> dict[str, Any]:
50+
"""Load ``fixtures/expected.yaml`` once per session."""
51+
with EXPECTED_PATH.open() as fh:
52+
return yaml.safe_load(fh)
53+
54+
55+
@pytest.fixture(scope="session")
56+
def sample_project_path() -> Path:
57+
"""Filesystem path to the fixture project."""
58+
return SAMPLE_PROJECT
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# Integration fixture — indexes into a real FalkorDB
63+
# ---------------------------------------------------------------------------
64+
65+
66+
def _falkordb_reachable() -> bool:
67+
"""Cheap probe so the integration fixture can self-skip in dev."""
68+
try:
69+
import socket
70+
71+
host = os.getenv("FALKORDB_HOST", "localhost")
72+
port = int(os.getenv("FALKORDB_PORT", 6379))
73+
with socket.create_connection((host, port), timeout=1):
74+
return True
75+
except OSError:
76+
return False
77+
78+
79+
@pytest.fixture(scope="session")
80+
def indexed_fixture(sample_project_path: Path) -> IndexedFixture:
81+
"""Index the sample project into a unique per-session graph.
82+
83+
Each test session creates a new graph named
84+
``code:sample_project:test-<uuid>`` so parallel CI shards never
85+
contend on the same graph. The graph is intentionally **not**
86+
cleaned up — short-lived CI runners discard the FalkorDB volume,
87+
and keeping it around helps post-mortem debugging on developer
88+
machines.
89+
90+
Uses :class:`api.analyzers.SourceAnalyzer` directly (instead of
91+
``Project.from_local_repository``) so the fixture doesn't need to
92+
be a git repository — analyzing a plain directory is exactly the
93+
code path the ``index_repo`` MCP tool exercises for non-git
94+
folders.
95+
"""
96+
97+
if not _falkordb_reachable():
98+
pytest.skip("FalkorDB not reachable on $FALKORDB_HOST:$FALKORDB_PORT")
99+
100+
# Import locally so unit-only tests don't pay the import cost.
101+
from api.analyzers.source_analyzer import SourceAnalyzer
102+
from api.graph import Graph
103+
104+
project_name = sample_project_path.name # "sample_project"
105+
branch = f"test-{uuid.uuid4().hex[:8]}"
106+
graph = Graph(project_name, branch=branch)
107+
108+
analyzer = SourceAnalyzer()
109+
analyzer.analyze_local_folder(str(sample_project_path), graph)
110+
111+
return IndexedFixture(
112+
project=project_name,
113+
branch=branch,
114+
graph_name=graph.name,
115+
path=sample_project_path,
116+
)

tests/mcp/fixtures/expected.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# MCP fixture assertion contract.
2+
#
3+
# Anything declared here is treated as load-bearing by tests/mcp/*. The
4+
# precise numeric counts depend on which analyzers run in CI (some test
5+
# environments skip the multilspy passes), so the per-label counts are
6+
# expressed as minimums (`>=`) rather than equalities. Named-symbol
7+
# assertions are exact.
8+
9+
# The graph is named after the fixture directory (per Project.from_local_repository).
10+
project_name: sample_project
11+
12+
# Minimum counts produced by the Python tree-sitter analyzer alone.
13+
# Java + C# add more when multilspy is available.
14+
counts_min:
15+
File: 4 # 4 python files (java/csharp may bump this)
16+
Class: 3 # BaseRepo, UserRepo, OrderRepo
17+
Function: 6 # entrypoint, service, db, BaseRepo.repo, UserRepo.repo, OrderRepo.repo
18+
19+
# Named callers / callees (used by T5 — get_callers / get_callees).
20+
calls:
21+
service:
22+
callers: ["entrypoint"]
23+
# service() instantiates UserRepo + OrderRepo and calls .repo() on each;
24+
# the analyzer encodes that as CALLS edges on the method.
25+
callees_any_of: ["repo", "UserRepo", "OrderRepo"]
26+
entrypoint:
27+
callers: []
28+
callees_any_of: ["service"]
29+
db:
30+
# db() is called by both subclasses via super().repo() -> BaseRepo.repo().
31+
callers_any_of: ["repo"]
32+
callees: []
33+
34+
# Known path between two named symbols (used by T7 — find_path).
35+
paths:
36+
- source: entrypoint
37+
dest: db
38+
min_paths: 1
39+
40+
# Prefix-search hits (used by T8 — search_code).
41+
search_prefixes:
42+
ent:
43+
must_include: ["entrypoint"]
44+
serv:
45+
must_include: ["service"]
46+
Repo:
47+
# case-insensitive prefix match on Searchable label
48+
must_include_any_of: ["BaseRepo", "UserRepo", "OrderRepo"]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Sample fixture project for the MCP test suite
2+
3+
This directory is consumed by `tests/mcp/conftest.py::indexed_fixture`. Every
4+
MCP tool ticket from T4 onward asserts against the assertions declared in
5+
`expected.yaml`.
6+
7+
## Canonical Python call graph
8+
9+
```
10+
entrypoint() -> service() -> {UserRepo,OrderRepo}.repo() -> db()
11+
```
12+
13+
Plus a small class hierarchy:
14+
15+
```
16+
BaseRepo
17+
├── UserRepo
18+
└── OrderRepo
19+
```
20+
21+
## Why three languages? (deferred)
22+
23+
The original T3 spec called for one Java + one C# file so multilspy's
24+
second-pass code paths would be exercised. In practice both analyzers
25+
demand a real Maven / .NET project layout at the **root** of the indexed
26+
tree, which would make this fixture awkward to co-host with the Python
27+
sample. The multilingual coverage is therefore deferred to a follow-up
28+
ticket (likely T16, which already pulls in additional languages).
29+
30+
T4-T8 only need Python, which this fixture covers in full.
31+
32+
## Stability contract
33+
34+
If you change this fixture, you must also update `expected.yaml`. Tests
35+
read counts and named symbols directly from that file so the assertion
36+
contract stays in lock-step.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Marks ``sample_project/python`` as a package so IMPORTS edges resolve."""
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Bottom of the canonical call chain."""
2+
3+
4+
def db() -> str:
5+
"""Leaf function — entrypoint -> service -> repo -> db."""
6+
return "db"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Entrypoint for the MCP test fixture project.
2+
3+
Call graph (must match ``expected.yaml``):
4+
5+
entrypoint() -> service() -> repo() -> db()
6+
"""
7+
8+
from .service import service
9+
10+
11+
def entrypoint() -> str:
12+
"""Top of the canonical call chain used by every MCP integration test."""
13+
return service()
14+
15+
16+
if __name__ == "__main__":
17+
entrypoint()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Repository layer for the MCP fixture project.
2+
3+
Exercises a small class hierarchy: ``BaseRepo`` <- ``UserRepo`` / ``OrderRepo``.
4+
"""
5+
6+
from .db import db
7+
8+
9+
class BaseRepo:
10+
"""Base class so the analyzer emits an INHERITS edge."""
11+
12+
def repo(self) -> str:
13+
return db()
14+
15+
16+
class UserRepo(BaseRepo):
17+
def repo(self) -> str:
18+
return "user:" + super().repo()
19+
20+
21+
class OrderRepo(BaseRepo):
22+
def repo(self) -> str:
23+
return "order:" + super().repo()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Service layer for the MCP fixture project."""
2+
3+
from .repo import UserRepo, OrderRepo
4+
5+
6+
def service() -> str:
7+
"""Middle of the canonical call chain: entrypoint -> service -> repo."""
8+
users = UserRepo()
9+
orders = OrderRepo()
10+
return users.repo() + ":" + orders.repo()

tests/mcp/test_fixture_contract.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""T3 — assertion contract sanity check.
2+
3+
These tests validate the *fixture itself*: that the YAML contract parses,
4+
that the sample-project tree on disk matches what the contract declares,
5+
and that the integration fixture can actually index the project against
6+
a live FalkorDB. Tool-specific assertions live in the per-tool ticket
7+
test modules (T4, T5, T7, T8 ...).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
from tests.mcp.conftest import EXPECTED_PATH, SAMPLE_PROJECT
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Pure-Python contract checks (always run)
21+
# ---------------------------------------------------------------------------
22+
23+
24+
def test_expected_yaml_exists():
25+
assert EXPECTED_PATH.is_file(), "fixtures/expected.yaml must exist"
26+
27+
28+
def test_expected_contract_shape(expected_contract):
29+
"""Required top-level keys are present and well-typed."""
30+
31+
assert expected_contract["project_name"] == "sample_project"
32+
33+
counts = expected_contract["counts_min"]
34+
for label in ("File", "Class", "Function"):
35+
assert label in counts and isinstance(counts[label], int) and counts[label] >= 0
36+
37+
calls = expected_contract["calls"]
38+
for sym in ("service", "entrypoint", "db"):
39+
assert sym in calls, f"calls.{sym} missing from expected.yaml"
40+
41+
paths = expected_contract["paths"]
42+
assert isinstance(paths, list) and len(paths) >= 1
43+
for p in paths:
44+
for k in ("source", "dest", "min_paths"):
45+
assert k in p, f"path entry missing key: {k}"
46+
47+
prefixes = expected_contract["search_prefixes"]
48+
assert "ent" in prefixes
49+
50+
51+
def test_sample_project_python_files_present():
52+
"""The Python tree the contract references must exist on disk."""
53+
py = SAMPLE_PROJECT / "python"
54+
for name in ("entrypoint.py", "service.py", "repo.py", "db.py", "__init__.py"):
55+
assert (py / name).is_file(), f"missing fixture file: python/{name}"
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Integration check — requires FalkorDB; self-skips when unreachable
60+
# ---------------------------------------------------------------------------
61+
62+
63+
def test_indexed_fixture_loads_minimum_counts(indexed_fixture, expected_contract):
64+
"""The fixture indexes cleanly and meets the minimum count contract.
65+
66+
Subsequent per-tool tickets (T4+) use ``indexed_fixture`` directly;
67+
this test exists so the fixture itself is regression-tested in
68+
isolation.
69+
"""
70+
71+
from api.graph import Graph
72+
73+
g = Graph(indexed_fixture.project, branch=indexed_fixture.branch)
74+
counts_min = expected_contract["counts_min"]
75+
76+
for label, minimum in counts_min.items():
77+
rows = g.g.query(f"MATCH (n:{label}) RETURN count(n) AS c").result_set
78+
actual = rows[0][0] if rows else 0
79+
assert actual >= minimum, (
80+
f"label {label}: expected >={minimum}, got {actual}"
81+
)

0 commit comments

Comments
 (0)