Migrate all repos into monorepo context folders

Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Global pytest configuration.
This repository's unit/integration tests must be deterministic and must not make
any outbound network calls.
As a safety net, this conftest blocks common Python networking primitives by
default.
If you *intentionally* need network access for a specific test (generally not
recommended), you can opt out with:
@pytest.mark.allow_network
or for an entire module:
pytestmark = pytest.mark.allow_network
"""
from __future__ import annotations
import socket
from typing import Any
import pytest
def _blocked(*args: Any, **kwargs: Any) -> None: # pragma: no cover
raise AssertionError(
"Outbound network access is blocked during tests. "
"Patch the relevant client call, or mark the test with "
"@pytest.mark.allow_network if this is truly required."
)
def _blocked_connect(self: socket.socket, address: Any) -> None: # pragma: no cover
# Allow local-only connections needed for event loops / self-pipes.
try:
host = address[0] if isinstance(address, tuple) and address else None
except Exception:
host = None
if host in ("127.0.0.1", "localhost", "::1"):
return _ORIG_SOCKET_CONNECT(self, address)
raise AssertionError(
"Outbound network access is blocked during tests. "
"Patch the relevant client call, or mark the test with "
"@pytest.mark.allow_network if this is truly required."
)
_ORIG_SOCKET_CONNECT = socket.socket.connect
@pytest.fixture(autouse=True)
def _block_network(
monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
) -> None:
"""Block outbound network unless explicitly allowed."""
if request.node.get_closest_marker("allow_network") is not None:
return
# Keep event-loop internals working (they rely on AF_UNIX socketpairs).
# Only block outbound connections.
monkeypatch.setattr(socket.socket, "connect", _blocked_connect, raising=True)
monkeypatch.setattr(socket, "create_connection", _blocked)
# Best-effort blocks for popular HTTP clients.
try: # urllib3 (requests)
import urllib3.connectionpool # type: ignore
monkeypatch.setattr(
urllib3.connectionpool.HTTPConnectionPool, "_new_conn", _blocked
)
monkeypatch.setattr(
urllib3.connectionpool.HTTPSConnectionPool, "_new_conn", _blocked
)
except Exception:
pass
try: # httpx
import httpx # type: ignore
monkeypatch.setattr(httpx.Client, "send", _blocked)
monkeypatch.setattr(httpx.AsyncClient, "send", _blocked)
except Exception:
pass
try: # aiohttp
import aiohttp # type: ignore
monkeypatch.setattr(aiohttp.ClientSession, "_request", _blocked)
except Exception:
pass
@@ -0,0 +1,176 @@
# filepath: tests/test_auto_tagger.py
"""Unit tests for :mod:`teamlandkarte_mcp.matching.auto_tagger`.
All tests mock :class:`~teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient`
— no real LLM calls are made.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.auto_tagger import AutoTagger
def _make_tagger(chat_completion_return: str) -> AutoTagger:
"""Build an AutoTagger backed by a mock client."""
client = MagicMock()
client.chat_completion = AsyncMock(return_value=chat_completion_return)
return AutoTagger(client=client)
def _make_failing_tagger(exc: Exception) -> AutoTagger:
"""Build an AutoTagger whose chat_completion always raises."""
client = MagicMock()
client.chat_completion = AsyncMock(side_effect=exc)
return AutoTagger(client=client)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_expand_adds_covered_required_terms() -> None:
"""LLM returns a valid addition → appended to existing list."""
tagger = _make_tagger(json.dumps({"additions": ["Machine Learning"]}))
result = await tagger.expand_competences(
required=["Machine Learning", "Python"],
existing=["ML", "Python"],
)
assert "Machine Learning" in result
assert "ML" in result
assert "Python" in result
@pytest.mark.asyncio
async def test_expand_no_additions_returns_existing_unchanged() -> None:
"""LLM returns empty additions → existing list unchanged."""
tagger = _make_tagger(json.dumps({"additions": []}))
existing = ["Python", "React"]
result = await tagger.expand_competences(
required=["Machine Learning"],
existing=existing,
)
assert result == existing
# ---------------------------------------------------------------------------
# Graceful degradation on errors
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_expand_api_error_returns_existing() -> None:
"""LLM raises AzureAPIError → returns existing unchanged, no propagation."""
tagger = _make_failing_tagger(AzureAPIError("timeout"))
existing = ["Python"]
result = await tagger.expand_competences(
required=["Machine Learning"],
existing=existing,
)
assert result == existing
@pytest.mark.asyncio
async def test_expand_generic_exception_returns_existing() -> None:
"""Any unexpected exception → graceful degradation, returns existing."""
tagger = _make_failing_tagger(RuntimeError("unexpected"))
existing = ["TypeScript"]
result = await tagger.expand_competences(
required=["JavaScript"],
existing=existing,
)
assert result == existing
@pytest.mark.asyncio
async def test_expand_malformed_json_returns_existing() -> None:
"""Unparseable LLM response → existing list returned unchanged."""
tagger = _make_tagger("not valid json {{")
existing = ["Go"]
result = await tagger.expand_competences(
required=["Rust"],
existing=existing,
)
assert result == existing
@pytest.mark.asyncio
async def test_expand_missing_additions_key_returns_existing() -> None:
"""Valid JSON but no 'additions' key → treated as empty additions."""
tagger = _make_tagger(json.dumps({"result": []}))
existing = ["Kotlin"]
result = await tagger.expand_competences(
required=["Java"],
existing=existing,
)
assert result == existing
# ---------------------------------------------------------------------------
# Hallucination guard
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_expand_hallucinated_term_dropped() -> None:
"""Addition not in 'required' (hallucination) is silently dropped."""
tagger = _make_tagger(json.dumps({"additions": ["Rust", "Machine Learning"]}))
result = await tagger.expand_competences(
required=["Machine Learning"],
existing=["ML"],
)
assert "Rust" not in result
assert "Machine Learning" in result
# ---------------------------------------------------------------------------
# Duplicate guard
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_expand_no_duplicate_if_already_in_existing() -> None:
"""Addition already in existing → not duplicated in result."""
tagger = _make_tagger(json.dumps({"additions": ["Python"]}))
result = await tagger.expand_competences(
required=["Python"],
existing=["Python", "Pandas"],
)
assert result.count("Python") == 1
# ---------------------------------------------------------------------------
# Edge cases: empty inputs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_expand_empty_required_returns_existing_no_llm_call() -> None:
"""Empty required list → existing returned immediately, no LLM call."""
client = MagicMock()
client.chat_completion = AsyncMock()
tagger = AutoTagger(client=client)
existing = ["Python"]
result = await tagger.expand_competences(required=[], existing=existing)
assert result == existing
client.chat_completion.assert_not_called()
@pytest.mark.asyncio
async def test_expand_empty_existing_returns_empty_no_llm_call() -> None:
"""Empty existing list → empty list returned, no LLM call."""
client = MagicMock()
client.chat_completion = AsyncMock()
tagger = AutoTagger(client=client)
result = await tagger.expand_competences(
required=["Python"],
existing=[],
)
assert result == []
client.chat_completion.assert_not_called()
@@ -0,0 +1,87 @@
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from teamlandkarte_mcp.azure.openai_client import AzureAPIError, AzureOpenAIClient
@pytest.mark.asyncio
async def test_chat_completion_success():
client = AzureOpenAIClient(
endpoint="https://example.openai.azure.com",
api_version="2024-02-15-preview",
chat_deployment="gpt-4o",
llm_api_key="test-key",
)
# Mock the internal chat client
fake_choice = MagicMock()
fake_choice.message.content = '{"result": "ok"}'
fake_resp = MagicMock()
fake_resp.choices = [fake_choice]
fake_chat = MagicMock()
fake_chat.chat.completions.create = AsyncMock(return_value=fake_resp)
client._chat = cast(Any, fake_chat)
result = await client.chat_completion(system="sys", user="usr")
assert result == '{"result": "ok"}'
@pytest.mark.asyncio
async def test_chat_completion_retries_on_timeout():
client = AzureOpenAIClient(
endpoint="https://example.openai.azure.com",
api_version="2024-02-15-preview",
chat_deployment="gpt-4o",
llm_api_key="test-key",
max_retries=3,
timeout_s=0.01,
)
# First call times out, second succeeds
fake_choice = MagicMock()
fake_choice.message.content = '{"ok": true}'
fake_resp = MagicMock()
fake_resp.choices = [fake_choice]
call_count = 0
async def _fake_create(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("transient error")
return fake_resp
fake_chat = MagicMock()
fake_chat.chat.completions.create = _fake_create
client._chat = cast(Any, fake_chat)
result = await client.chat_completion(system="sys", user="usr")
assert result == '{"ok": true}'
assert call_count == 2
@pytest.mark.asyncio
async def test_chat_completion_raises_after_all_retries():
client = AzureOpenAIClient(
endpoint="https://example.openai.azure.com",
api_version="2024-02-15-preview",
chat_deployment="gpt-4o",
llm_api_key="test-key",
max_retries=2,
timeout_s=0.01,
)
async def _always_fail(**kwargs):
raise RuntimeError("persistent error")
fake_chat = MagicMock()
fake_chat.chat.completions.create = _always_fail
client._chat = cast(Any, fake_chat)
with pytest.raises(AzureAPIError, match="persistent error"):
await client.chat_completion(system="sys", user="usr")
@@ -0,0 +1,121 @@
# Feature: llm-batch-matching, Property 1: Vollständigkeit der Ergebnisse
"""Property-based test for result completeness (partition) in batch matching.
For any list of capacities and any mix of LLM successes/failures, the total
number of items across all ``by_category`` buckets plus the number of entries
in ``errors`` must equal the number of input candidates. No candidate may be
lost or duplicated.
The fake LLM is driven by a Hypothesis-generated boolean mask: position ``i``
decides whether the call for capacity ``i+1`` raises ``AzureAPIError``
(``True``) or returns a valid JSON payload (``False``).
**Validates: Requirements 1.1, 1.4, 3.2, 4.2, 4.3**
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
class _MaskLlm:
"""Returns valid JSON for mask[i] is False; raises AzureAPIError for True."""
def __init__(self, mask: list[bool]) -> None:
self._mask = mask
self._call_index = 0
async def chat_completion(self, system: str, user: str) -> str:
idx = self._call_index
self._call_index += 1
if self._mask[idx]:
raise AzureAPIError("simulated")
return json.dumps({"category": "Top", "rationale": "ok"})
class _StubDb:
"""Minimal DBClient double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _make_capacities(n: int) -> list[Capacity]:
return [
Capacity(
id=i + 1,
owner_name=f"o{i + 1}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
for i in range(n)
]
@settings(max_examples=100, deadline=None)
@given(mask=st.lists(st.booleans(), min_size=1, max_size=20))
def test_batch_completeness_partition(mask: list[bool]) -> None:
"""Property 1: all input candidates appear exactly once in output.
The sum of items across all by_category buckets plus the number of
errors must equal the number of input capacities. Additionally, all
item_ids across by_category and errors must be unique (no duplicates).
"""
matcher = LlmFulltextMatcher(db=_StubDb(), client=_MaskLlm(mask))
capacities = _make_capacities(len(mask))
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
result = asyncio.run(
matcher.match_capacities(
task_profile=task_profile,
capacities=capacities,
)
)
# Collect all item_ids from by_category
category_ids: list[str] = []
for items in result.by_category.values():
for it in items:
category_ids.append(it.item_id)
# Collect all item_ids from errors
error_ids: list[str] = [e.item_id for e in result.errors]
# Property: completeness no candidate lost
total = len(category_ids) + len(error_ids)
assert total == len(capacities), (
f"Expected {len(capacities)} total items, got {total} "
f"({len(category_ids)} categorized + {len(error_ids)} errors)"
)
# Property: no duplicates all item_ids are unique
all_ids = category_ids + error_ids
assert len(all_ids) == len(set(all_ids)), (
f"Duplicate item_ids detected: {len(all_ids)} total vs "
f"{len(set(all_ids))} unique"
)
@@ -0,0 +1,120 @@
# Feature: llm-batch-matching, Property 2: Concurrency-Begrenzung
"""Property-based test for concurrency limiting in batch matching.
For any number of candidates and any valid ``max_concurrency`` value, the
number of simultaneously running LLM calls must never exceed the configured
``max_concurrency`` limit.
A mock LLM client uses an asyncio counter (protected by a lock) to track
concurrent invocations and records the maximum observed concurrency. After
matching completes, we assert that the peak never exceeded the configured
limit.
**Validates: Requirements 1.2**
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
class _ConcurrencyTracker:
"""Mock LLM client that tracks peak concurrent invocations."""
def __init__(self) -> None:
self._current = 0
self._max_seen = 0
self._lock = asyncio.Lock()
async def chat_completion(self, system: str, user: str) -> str:
async with self._lock:
self._current += 1
if self._current > self._max_seen:
self._max_seen = self._current
await asyncio.sleep(0.01) # simulate I/O
async with self._lock:
self._current -= 1
return json.dumps({"category": "Top", "rationale": "ok"})
@property
def max_concurrent(self) -> int:
return self._max_seen
class _StubDb:
"""Minimal DBClient double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _make_capacities(n: int) -> list[Capacity]:
return [
Capacity(
id=i + 1,
owner_name=f"o{i + 1}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
for i in range(n)
]
@settings(max_examples=50, deadline=None)
@given(
max_concurrency=st.integers(min_value=1, max_value=10),
num_candidates=st.integers(min_value=1, max_value=30),
)
def test_batch_concurrency_limit(
max_concurrency: int, num_candidates: int
) -> None:
"""Property 2: peak concurrent LLM calls never exceed max_concurrency.
We generate a random max_concurrency (1-10) and a random number of
candidates (1-30), then verify that the concurrency tracker never
observed more simultaneous calls than the configured limit.
"""
tracker = _ConcurrencyTracker()
matcher = LlmFulltextMatcher(
db=_StubDb(),
client=tracker,
max_concurrency=max_concurrency,
)
capacities = _make_capacities(num_candidates)
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
asyncio.run(
matcher.match_capacities(
task_profile=task_profile,
capacities=capacities,
)
)
assert tracker.max_concurrent <= max_concurrency, (
f"Observed {tracker.max_concurrent} concurrent calls, "
f"but max_concurrency was set to {max_concurrency}"
)
@@ -0,0 +1,33 @@
# Feature: llm-batch-matching, Property 6: Config-Validierung
"""Property-based test for azure_openai.max_concurrency config validation.
**Validates: Requirements 2.1, 2.3, 2.4**
"""
from hypothesis import given
from hypothesis import strategies as st
import pytest
from teamlandkarte_mcp.config import (
AzureOpenAIConfig,
ConfigError,
_parse_azure_openai,
)
@given(n=st.integers(min_value=-100, max_value=100))
def test_max_concurrency_valid_iff_in_range(n: int) -> None:
"""max_concurrency succeeds iff 1 <= n <= 50, else ConfigError."""
cfg = {
"max_concurrency": n,
"endpoint": "https://test.example.com",
"chat_deployment": "test",
}
if 1 <= n <= 50:
result = _parse_azure_openai(cfg)
assert isinstance(result, AzureOpenAIConfig)
assert result.max_concurrency == n
else:
with pytest.raises(ConfigError):
_parse_azure_openai(cfg)
@@ -0,0 +1,193 @@
# Feature: llm-batch-matching, Property 7: Logging-Konsistenz
"""Property-based test for logging consistency in batch matching.
For every execution of ``match_capacities`` with at least one candidate, the
INFO-level log messages (start and end) must contain the correct candidate
count, the configured concurrency limit, the number of successful
categorizations, and the number of errors. The sum of successes and errors in
the end log must equal the input count.
The fake LLM is driven by a Hypothesis-generated boolean mask: position ``i``
decides whether the call for capacity ``i+1`` raises ``AzureAPIError``
(``True``) or returns a valid JSON payload (``False``).
**Validates: Requirements 6.1, 6.2**
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import contextmanager
from typing import Any, Generator
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_LOGGER_NAME = "teamlandkarte_mcp.matching.llm_fulltext_matcher"
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _MaskLlm:
"""Returns valid JSON for mask[i] is False; raises AzureAPIError for True."""
def __init__(self, mask: list[bool]) -> None:
self._mask = mask
self._call_index = 0
async def chat_completion(self, system: str, user: str) -> str:
idx = self._call_index
self._call_index += 1
if self._mask[idx]:
raise AzureAPIError("simulated")
return json.dumps({"category": "Top", "rationale": "ok"})
class _StubDb:
"""Minimal DBClient double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _make_capacities(n: int) -> list[Capacity]:
return [
Capacity(
id=i + 1,
owner_name=f"o{i + 1}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
for i in range(n)
]
# ---------------------------------------------------------------------------
# Log capture context manager (hypothesis doesn't support pytest fixtures)
# ---------------------------------------------------------------------------
class _ListHandler(logging.Handler):
"""Simple handler that appends records to a list."""
def __init__(self, records: list[logging.LogRecord]) -> None:
super().__init__()
self._records = records
def emit(self, record: logging.LogRecord) -> None:
self._records.append(record)
@contextmanager
def _capture_logs(
logger_name: str, level: int
) -> Generator[list[logging.LogRecord], None, None]:
"""Capture log records from a named logger for the duration of the block."""
logger = logging.getLogger(logger_name)
records: list[logging.LogRecord] = []
handler = _ListHandler(records)
handler.setLevel(level)
logger.addHandler(handler)
old_level = logger.level
logger.setLevel(level)
try:
yield records
finally:
logger.removeHandler(handler)
logger.setLevel(old_level)
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(
mask=st.lists(st.booleans(), min_size=1, max_size=15),
max_concurrency=st.integers(min_value=1, max_value=5),
)
def test_batch_logging_consistency(mask: list[bool], max_concurrency: int) -> None:
"""Property 7: log messages contain correct counts.
Start log must contain the candidate count and max_concurrency.
End log must contain the number of categorized items and errors.
The sum of categorized + errors must equal the total candidates.
"""
tracker_client = _MaskLlm(mask)
matcher = LlmFulltextMatcher(
db=_StubDb(),
client=tracker_client,
max_concurrency=max_concurrency,
)
capacities = _make_capacities(len(mask))
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
with _capture_logs(_LOGGER_NAME, logging.INFO) as records:
asyncio.run(
matcher.match_capacities(
task_profile=task_profile, capacities=capacities
)
)
# Find start and end log messages
start_msgs = [r for r in records if "gestartet" in r.getMessage()]
end_msgs = [r for r in records if "abgeschlossen" in r.getMessage()]
assert len(start_msgs) == 1, f"Expected 1 start log, got {len(start_msgs)}"
assert len(end_msgs) == 1, f"Expected 1 end log, got {len(end_msgs)}"
start_msg = start_msgs[0].getMessage()
end_msg = end_msgs[0].getMessage()
# --- Start log: correct candidate count and max_concurrency ---
assert str(len(mask)) in start_msg, (
f"Expected candidate count {len(mask)} in start log: {start_msg}"
)
assert str(max_concurrency) in start_msg, (
f"Expected max_concurrency {max_concurrency} in start log: {start_msg}"
)
# --- End log: correct categorized and error counts ---
expected_errors = sum(1 for m in mask if m)
expected_success = sum(1 for m in mask if not m)
assert str(expected_success) in end_msg, (
f"Expected {expected_success} categorized in end log: {end_msg}"
)
assert str(expected_errors) in end_msg, (
f"Expected {expected_errors} errors in end log: {end_msg}"
)
# --- Consistency: categorized + errors == total candidates ---
# Parse the numbers from the end log message format:
# "Batch-Matching abgeschlossen: %.1fs, %d kategorisiert, %d Fehler"
# We verify the sum property by checking the actual values
assert expected_success + expected_errors == len(mask), (
f"Sum mismatch: {expected_success} + {expected_errors} != {len(mask)}"
)
@@ -0,0 +1,101 @@
# Feature: llm-batch-matching, Property 5: Korrekte Zuordnung (Response-Mapping)
"""Property-based test for correct response-to-candidate mapping in batch matching.
For any list of capacities with item_id-specific category assignments, each
item must land in exactly the category that the mock LLM returned for it.
This validates that concurrent execution does not mix up responses between
candidates.
**Validates: Requirements 3.4**
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_ID_RE = re.compile(r"ID:\s*(\S+)")
_CATEGORIES: tuple[str, ...] = ("Top", "Good", "Partial", "Low", "Irrelevant")
class _MapLlm:
def __init__(self, id_to_category: dict[str, str]) -> None:
self._map = id_to_category
async def chat_completion(self, system: str, user: str) -> str:
match = _ID_RE.search(user)
cap_id = match.group(1) if match else "unknown"
category = self._map.get(cap_id, "Irrelevant")
await asyncio.sleep(0.005) # simulate I/O
return json.dumps({"category": category, "rationale": f"r{cap_id}"})
class _StubDb:
def batch_get_capacity_descriptions(self, capacity_ids: list[Any]) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(self, capacity_ids: list[Any]) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(self, capacity_ids: list[Any]) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _make_capacity(cap_id: int) -> Capacity:
return Capacity(
id=cap_id,
owner_name=f"o{cap_id}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
@settings(max_examples=100, deadline=None)
@given(
ids=st.lists(st.integers(min_value=1, max_value=99), min_size=2, max_size=15, unique=True),
max_concurrency=st.integers(min_value=1, max_value=5),
data=st.data(),
)
def test_batch_correct_mapping(
ids: list[int], max_concurrency: int, data: st.DataObject
) -> None:
"""Property 5: each item lands in exactly the category the mock assigned."""
id_to_category = {str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids}
capacities = [_make_capacity(i) for i in ids]
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
matcher = LlmFulltextMatcher(
db=_StubDb(), client=_MapLlm(id_to_category), max_concurrency=max_concurrency,
)
result = asyncio.run(
matcher.match_capacities(task_profile=task_profile, capacities=capacities)
)
# Build a mapping from item_id to actual category in the result
actual_mapping: dict[str, str] = {}
for cat, items in result.by_category.items():
for it in items:
actual_mapping[it.item_id] = cat
# Assert: every capacity ended up in the correct category
for cap_id_str, expected_cat in id_to_category.items():
assert cap_id_str in actual_mapping, (
f"Capacity {cap_id_str} not found in any category"
)
assert actual_mapping[cap_id_str] == expected_cat, (
f"Capacity {cap_id_str}: expected {expected_cat}, "
f"got {actual_mapping[cap_id_str]}"
)
@@ -0,0 +1,161 @@
# Feature: llm-batch-matching, Property 4: Eingabereihenfolge-Unabhängigkeit
"""Property-based test for input order independence under concurrent batch matching.
For any permutation of the input candidate list and any max_concurrency value,
the results (by_category and errors) must be identical — the input order has
no influence on the output.
**Validates: Requirements 3.3**
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_ID_RE = re.compile(r"ID:\s*(\S+)")
_CATEGORIES: tuple[str, ...] = ("Top", "Good", "Partial", "Low", "Irrelevant")
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _MapLlm:
"""Returns a JSON payload whose category depends on the prompt's ID line.
Parses the ``ID: <capacity_id>`` line from the user prompt to determine
which category to return. Includes a small sleep to simulate I/O and
exercise concurrency paths.
"""
def __init__(self, id_to_category: dict[str, str]) -> None:
self._map = id_to_category
async def chat_completion(self, system: str, user: str) -> str:
match = _ID_RE.search(user)
cap_id = match.group(1) if match else "unknown"
category = self._map.get(cap_id, "Irrelevant")
await asyncio.sleep(0.005)
return json.dumps({"category": category, "rationale": f"r{cap_id}"})
class _StubDb:
"""Minimal DBClient double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_capacity(cap_id: int) -> Capacity:
return Capacity(
id=cap_id,
owner_name=f"o{cap_id}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(
ids=st.lists(
st.integers(min_value=1, max_value=99),
min_size=2,
max_size=10,
unique=True,
),
max_concurrency=st.integers(min_value=1, max_value=5),
data=st.data(),
)
def test_batch_order_independence(
ids: list[int], max_concurrency: int, data: st.DataObject
) -> None:
"""Property 4: input order has no influence on the output.
Generates random unique capacity IDs, assigns random categories,
creates two LlmFulltextMatcher instances with the same max_concurrency,
runs match_capacities with the original list and a random permutation,
and asserts that by_category and errors are identical.
"""
id_to_category = {
str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids
}
capacities = [_make_capacity(i) for i in ids]
permuted = data.draw(st.permutations(capacities))
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
matcher_a = LlmFulltextMatcher(
db=_StubDb(),
client=_MapLlm(id_to_category),
max_concurrency=max_concurrency,
)
result_a = asyncio.run(
matcher_a.match_capacities(
task_profile=task_profile, capacities=capacities
)
)
matcher_b = LlmFulltextMatcher(
db=_StubDb(),
client=_MapLlm(id_to_category),
max_concurrency=max_concurrency,
)
result_b = asyncio.run(
matcher_b.match_capacities(
task_profile=task_profile, capacities=permuted
)
)
# Assert: by_category results are identical (same item_ids per category)
for cat in _CATEGORIES:
ids_a = [it.item_id for it in result_a.by_category[cat]]
ids_b = [it.item_id for it in result_b.by_category[cat]]
assert ids_a == ids_b, (
f"Category {cat} differs between original and permuted: "
f"{ids_a} vs {ids_b}"
)
# Assert: errors lists are identical
errors_a = [e.item_id for e in result_a.errors]
errors_b = [e.item_id for e in result_b.errors]
assert errors_a == errors_b, (
f"Errors differ between original and permuted: "
f"{errors_a} vs {errors_b}"
)
@@ -0,0 +1,143 @@
# Feature: llm-batch-matching, Property 3: Deterministische Sortierung
"""Property-based test for deterministic sorting under concurrent batch matching.
For any list of capacities with random category assignments and any
max_concurrency value, the results within each category must be sorted
by item_id ascending (lexicographic), and the errors list must also be
sorted by item_id.
**Validates: Requirements 3.1**
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_ID_RE = re.compile(r"ID:\s*(\S+)")
_CATEGORIES: tuple[str, ...] = ("Top", "Good", "Partial", "Low", "Irrelevant")
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _MapLlm:
"""Returns a JSON payload whose category depends on the prompt's ID line.
Parses the ``ID: <capacity_id>`` line from the user prompt to determine
which category to return. Includes a small sleep to simulate I/O and
exercise concurrency paths.
"""
def __init__(self, id_to_category: dict[str, str]) -> None:
self._map = id_to_category
async def chat_completion(self, system: str, user: str) -> str:
match = _ID_RE.search(user)
cap_id = match.group(1) if match else "unknown"
category = self._map.get(cap_id, "Irrelevant")
await asyncio.sleep(0.005) # simulate I/O to exercise concurrency
return json.dumps({"category": category, "rationale": f"r{cap_id}"})
class _StubDb:
"""Minimal DBClient double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_capacity(cap_id: int) -> Capacity:
return Capacity(
id=cap_id,
owner_name=f"o{cap_id}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(
ids=st.lists(
st.integers(min_value=1, max_value=99),
min_size=2,
max_size=15,
unique=True,
),
max_concurrency=st.integers(min_value=1, max_value=5),
data=st.data(),
)
def test_batch_sort_deterministic(
ids: list[int], max_concurrency: int, data: st.DataObject
) -> None:
"""Property 3: results within each category are sorted by item_id ascending.
Generates random capacity IDs, assigns random categories, and runs
match_capacities with a random max_concurrency value. Asserts that
within each category, items are sorted by item_id ascending
(lexicographic), and the errors list is also sorted by item_id.
"""
id_to_category = {
str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids
}
capacities = [_make_capacity(i) for i in ids]
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
matcher = LlmFulltextMatcher(
db=_StubDb(),
client=_MapLlm(id_to_category),
max_concurrency=max_concurrency,
)
result = asyncio.run(
matcher.match_capacities(
task_profile=task_profile, capacities=capacities
)
)
# Assert: within each category, items are sorted by item_id ascending
for cat in _CATEGORIES:
item_ids = [it.item_id for it in result.by_category[cat]]
assert item_ids == sorted(item_ids), (
f"Category {cat} not sorted ascending: {item_ids}"
)
# Assert: errors list is also sorted by item_id ascending
error_ids = [e.item_id for e in result.errors]
assert error_ids == sorted(error_ids), f"Errors not sorted: {error_ids}"
@@ -0,0 +1,177 @@
"""Unit tests for LLM batch-matching scenarios.
Covers:
- Empty input returns immediately without LLM calls
- All errors: no exception, complete error list
- Default max_concurrency value is 5
- Single error does not affect other candidates
Anforderungen: 4.2, 4.3, 4.4, 5.3
"""
from __future__ import annotations
import asyncio
import json
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant")
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _StubDb:
def batch_get_capacity_descriptions(self, capacity_ids):
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(self, capacity_ids):
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(self, capacity_ids):
return {str(i): [] for i in capacity_ids}
class _StaticLlm:
"""Always returns the same response or raises."""
def __init__(self, response=None, exc=None):
self._response = response
self._exc = exc
self.call_count = 0
async def chat_completion(self, system, user):
self.call_count += 1
if self._exc:
raise self._exc
return self._response
class _FailOneLlm:
"""Fails on a specific call index, succeeds on others."""
def __init__(self, fail_index: int):
self._fail_index = fail_index
self._call_index = 0
async def chat_completion(self, system, user):
idx = self._call_index
self._call_index += 1
if idx == self._fail_index:
raise AzureAPIError("simulated failure")
return json.dumps({"category": "Top", "rationale": "ok"})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_capacities(n: int) -> list[Capacity]:
return [
Capacity(
id=i + 1,
owner_name=f"owner{i + 1}",
role_name="Engineer",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
for i in range(n)
]
def _task_profile() -> TaskProfile:
return TaskProfile(id="t1", title="Task", description="Desc", skills=[])
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_empty_input_returns_immediately_without_llm_calls():
"""Empty capacities list returns empty result, no LLM calls made."""
llm = _StaticLlm(response=json.dumps({"category": "Top", "rationale": "x"}))
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
result = asyncio.run(
matcher.match_capacities(task_profile=_task_profile(), capacities=[])
)
# All 5 categories present but empty
assert set(result.by_category.keys()) == set(_CATEGORIES)
for cat in _CATEGORIES:
assert result.by_category[cat] == []
# No errors
assert result.errors == []
# No LLM calls were made
assert llm.call_count == 0
def test_all_errors_no_exception_complete_error_list():
"""When all LLM calls fail, no exception is thrown and errors list is complete."""
n = 5
llm = _StaticLlm(exc=AzureAPIError("total failure"))
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
capacities = _make_capacities(n)
result = asyncio.run(
matcher.match_capacities(task_profile=_task_profile(), capacities=capacities)
)
# All categories empty
for cat in _CATEGORIES:
assert result.by_category[cat] == []
# All candidates appear in errors
assert len(result.errors) == n
error_ids = {e.item_id for e in result.errors}
expected_ids = {str(i + 1) for i in range(n)}
assert error_ids == expected_ids
def test_default_max_concurrency_is_five():
"""LlmFulltextMatcher without explicit max_concurrency uses 5."""
llm = _StaticLlm()
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
assert matcher._max_concurrency == 5
def test_single_error_does_not_affect_others():
"""One failing LLM call does not prevent other candidates from being categorized."""
n = 5
fail_index = 2 # The 3rd call (index 2) will fail
llm = _FailOneLlm(fail_index=fail_index)
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
capacities = _make_capacities(n)
result = asyncio.run(
matcher.match_capacities(task_profile=_task_profile(), capacities=capacities)
)
# Exactly one error
assert len(result.errors) == 1
# The other 4 are categorized (all in "Top" per _FailOneLlm)
categorized_items = []
for items in result.by_category.values():
categorized_items.extend(items)
assert len(categorized_items) == n - 1
# Total partition is preserved
assert len(categorized_items) + len(result.errors) == n
# All categorized items are in "Top"
for item in categorized_items:
assert item.category == "Top"
+245
View File
@@ -0,0 +1,245 @@
# filepath: tests/test_bm25.py
"""Unit tests for :mod:`teamlandkarte_mcp.matching.bm25`.
Covers tokenization, :class:`Bm25Index` behaviour, and the
:func:`bm25_rank_competences` convenience wrapper.
"""
from __future__ import annotations
from teamlandkarte_mcp.matching.bm25 import (
Bm25Index,
_tokenize,
bm25_rank_competences,
)
# ---------------------------------------------------------------------------
# Tokenization
# ---------------------------------------------------------------------------
def test_tokenize_lowercase() -> None:
"""Tokens are always lowercase."""
assert _tokenize("Python") == ["python"]
def test_tokenize_slash() -> None:
"""Slashes split tokens."""
assert _tokenize("CI/CD Pipeline") == ["ci", "cd", "pipeline"]
def test_tokenize_parentheses_and_space() -> None:
"""Parentheses and spaces split tokens; PWA is its own token."""
assert _tokenize("Progressive Web App (PWA)") == [
"progressive",
"web",
"app",
"pwa",
]
def test_tokenize_dot() -> None:
"""Dots split tokens (e.g. React.js → react, js)."""
assert _tokenize("React.js") == ["react", "js"]
def test_tokenize_hyphen() -> None:
"""Hyphens split tokens."""
assert _tokenize("Type-Script") == ["type", "script"]
def test_tokenize_empty_string() -> None:
"""Empty string produces empty token list."""
assert _tokenize("") == []
def test_tokenize_only_special_chars() -> None:
"""String of only special characters produces empty list."""
assert _tokenize("---///") == []
# ---------------------------------------------------------------------------
# Bm25Index — empty corpus
# ---------------------------------------------------------------------------
def test_bm25_index_empty_corpus_rank_returns_empty() -> None:
"""Empty corpus → rank() returns empty list."""
index = Bm25Index(corpus=[])
assert index.rank("Python") == []
# ---------------------------------------------------------------------------
# Bm25Index — exact match
# ---------------------------------------------------------------------------
def test_bm25_index_exact_match_score_positive() -> None:
"""Exact match produces a positive BM25 score in a realistic-sized corpus.
A corpus of five distinct skills ensures each term's IDF is positive
(no term appears in more than half the documents), so BM25 assigns a
meaningful positive score to the matching document.
"""
corpus = ["Python", "JavaScript", "Java", "Go", "Ruby"]
index = Bm25Index(corpus=corpus)
result = index.rank("Python")
py_score = next(s for d, s in result if d == "Python")
assert py_score > 0.0
def test_bm25_index_case_insensitive_match() -> None:
"""BM25 matching is case-insensitive (tokenization lowercases both sides).
Uses a realistic-sized corpus so IDF weights are positive.
"""
corpus = ["python", "JavaScript", "Java", "Go", "Ruby"]
index = Bm25Index(corpus=corpus)
result = index.rank("Python")
py_score = next(s for d, s in result if d == "python")
assert py_score > 0.0
# ---------------------------------------------------------------------------
# Bm25Index — no token overlap
# ---------------------------------------------------------------------------
def test_bm25_index_no_token_overlap_score_zero() -> None:
"""No shared token between query and document → score 0.0."""
index = Bm25Index(corpus=["JavaScript"])
result = index.rank("Python")
assert len(result) == 1
assert result[0][0] == "JavaScript"
assert result[0][1] == 0.0
def test_bm25_index_partial_token_overlap() -> None:
"""Partial token overlap gives positive score in a realistic-sized corpus.
"Machine Learning" shares the "learning" token with "Deep Learning".
A five-document corpus keeps "learning" IDF positive (it appears in 2/5
docs, well below the 50 % threshold that would zero out the IDF).
"""
corpus = ["Deep Learning", "Python", "JavaScript", "Java", "Go"]
index = Bm25Index(corpus=corpus)
result = index.rank("Machine Learning")
dl_score = next(s for d, s in result if d == "Deep Learning")
assert dl_score > 0.0
# ---------------------------------------------------------------------------
# Bm25Index — multiple documents, ranking order
# ---------------------------------------------------------------------------
def test_bm25_index_best_match_ranked_first() -> None:
"""The document with the highest score appears first.
Uses five documents so all query tokens have positive IDF and the
exact-match document outscores partial-match documents.
"""
corpus = ["Machine Learning", "Deep Learning", "JavaScript", "Python", "Java"]
index = Bm25Index(corpus=corpus)
result = index.rank("Machine Learning")
assert result[0][0] == "Machine Learning"
assert result[0][1] > result[1][1]
def test_bm25_index_zero_score_documents_included() -> None:
"""Documents with score 0.0 are included in the ranked list.
Uses a realistic-sized corpus so the Python document itself gets a
positive score, while the unrelated JavaScript document correctly
scores 0.0 (no token overlap).
"""
corpus = ["Python", "JavaScript", "Java", "Go", "Ruby"]
index = Bm25Index(corpus=corpus)
result = index.rank("Python")
docs = [r[0] for r in result]
assert "JavaScript" in docs
js_score = next(s for d, s in result if d == "JavaScript")
assert js_score == 0.0
def test_bm25_index_result_sorted_descending() -> None:
"""Ranked list is sorted by score descending.
Uses a realistic-sized corpus to ensure stable positive IDF values.
"""
corpus = ["Python", "Python Advanced", "JavaScript", "Java", "Go"]
index = Bm25Index(corpus=corpus)
result = index.rank("Python")
scores = [s for _, s in result]
assert scores == sorted(scores, reverse=True)
# ---------------------------------------------------------------------------
# bm25_rank_competences convenience wrapper
# ---------------------------------------------------------------------------
def test_bm25_rank_competences_empty_candidates() -> None:
"""Empty candidate list returns empty list."""
assert bm25_rank_competences("Python", []) == []
def test_bm25_rank_competences_consistent_with_index() -> None:
"""Convenience wrapper produces same result as Bm25Index.rank()."""
candidates = ["Python", "JavaScript", "Machine Learning", "Java", "Go"]
index_result = Bm25Index(corpus=candidates).rank("Python")
wrapper_result = bm25_rank_competences("Python", candidates)
assert index_result == wrapper_result
# ---------------------------------------------------------------------------
# Global-index filtering
# ---------------------------------------------------------------------------
def test_bm25_index_rank_filters_to_candidate_subset() -> None:
"""Scores from a global index can be filtered to a candidate's own skills.
This mirrors the production path in ``_bm25_rrf_similarity`` where a
global ``Bm25Index`` is built over all candidates and then the result is
filtered to only include the current candidate's competences.
"""
global_corpus = [
"Python",
"Machine Learning",
"JavaScript",
"TypeScript",
"Java",
"Go",
"Ruby",
]
global_index = Bm25Index(corpus=global_corpus)
# Simulate one candidate who only knows Python + Machine Learning
candidate_skills = {"Python", "Machine Learning"}
all_ranked = global_index.rank("Python")
filtered = [(doc, score) for doc, score in all_ranked if doc in candidate_skills]
assert len(filtered) == 2
docs = {d for d, _ in filtered}
assert docs == candidate_skills
# Python should score higher than Machine Learning for query "Python"
py_score = next(s for d, s in filtered if d == "Python")
ml_score = next(s for d, s in filtered if d == "Machine Learning")
assert py_score > ml_score
def test_bm25_ubiquitous_term_clamped_to_zero() -> None:
"""A term that appears in more than half the corpus gets IDF ≤ 0.
``rank()`` clamps the resulting negative BM25 score to 0.0 — the term
is non-discriminative and should not contribute positively to any match.
"""
# "dev" appears in 4 out of 5 documents → IDF < 0
corpus = ["dev Python", "dev Java", "dev Go", "dev Ruby", "JavaScript"]
index = Bm25Index(corpus=corpus)
result = index.rank("dev")
scores = [s for _, s in result]
# All scores must be ≥ 0 (negative raw scores clamped to 0.0)
assert all(s >= 0.0 for s in scores)
@@ -0,0 +1,279 @@
"""Unit tests for the capacity details enrichment feature.
Tests verify that get_capacity_details correctly displays description,
references, and certificates sections with proper formatting and ordering.
"""
from __future__ import annotations
from datetime import date
import pytest
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity, Task
class _FakeDB:
"""Fake DB client for testing get_capacity_details enrichment."""
def __init__(
self,
caps: list[Capacity],
*,
description: str | None = None,
references: list[dict] | None = None,
certificates: list[str] | None = None,
) -> None:
self._caps = caps
self._description = description
self._references = references if references is not None else []
self._certificates = certificates if certificates is not None else []
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Cloud Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["AWS"]
def get_open_tasks(self, limit: int = 20) -> list[Task]:
return []
def get_task_by_id(self, task_id: str):
return None
def get_task_by_name(self, name: str):
return None
def get_all_capacities_with_competences(self) -> list[Capacity]:
return list(self._caps)
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
return list(self._caps)[:limit]
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
cid = int(capacity_id)
for c in self._caps:
if int(c.id) == cid:
return c
return None
def get_capacity_description(self, capacity_id: int | str) -> str | None:
return self._description
def get_capacity_references(self, capacity_id: int | str) -> list[dict]:
return self._references
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
return self._certificates
def _make_config(tmp_path) -> str:
"""Write a minimal config.toml and return its path."""
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
return str(cfg)
def _make_capacity(cap_id: int = 1) -> Capacity:
return Capacity(
id=cap_id,
owner_name="Team Alpha",
role_name="Cloud Engineer",
role_level=None,
begin_date=date(2026, 1, 1),
end_date=date(2026, 6, 30),
competences=["AWS", "Terraform"],
)
# ---------------------------------------------------------------------------
# 2.1 Test full data scenario
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_full_data_scenario(monkeypatch, tmp_path) -> None:
"""All enrichment sections are rendered with correct content and ordering."""
description = "Erfahrener Cloud-Architekt mit Fokus auf AWS-Infrastruktur."
references = [
{"partner_name": "Deutsche Bahn", "projects": "Projekt Reiseportal, Projekt Fahrplan"},
{"partner_name": "", "projects": "Internes Migrationsprojekt"},
{"partner_name": "Siemens", "projects": "IoT Platform"},
]
certificates = [
"AWS Solutions Architect Professional",
"Kubernetes Administrator (CKA)",
]
cap = _make_capacity()
fake_db = _FakeDB(
[cap],
description=description,
references=references,
certificates=certificates,
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: fake_db)
srv = build_server(_make_config(tmp_path))
content, structured = await srv.call_tool(
"get_capacity_details", {"capacity_id": 1}
)
out = (structured or {}).get("result") or getattr(content[0], "text", "")
# Section headers present
assert "## Beschreibung" in out
assert "## Referenzen" in out
assert "## Zertifizierungen" in out
assert "## Next steps" in out
# Description content
assert description in out
# References content: partner with name uses bold formatting
assert "**Deutsche Bahn**: Projekt Reiseportal, Projekt Fahrplan" in out
# Reference without partner_name shows only projects (no bold prefix)
assert "- Internes Migrationsprojekt" in out
assert "**Siemens**: IoT Platform" in out
# Ensure the empty-partner reference does NOT have bold empty prefix
assert "****:" not in out
# Certificates content
assert "- AWS Solutions Architect Professional" in out
assert "- Kubernetes Administrator (CKA)" in out
# Section ordering: Beschreibung < Referenzen < Zertifizierungen < Next steps
idx_beschreibung = out.index("## Beschreibung")
idx_referenzen = out.index("## Referenzen")
idx_zertifizierungen = out.index("## Zertifizierungen")
idx_next_steps = out.index("## Next steps")
assert idx_beschreibung < idx_referenzen < idx_zertifizierungen < idx_next_steps
# Capacity table appears before all enrichment sections
assert "| capacity_id |" in out
idx_table = out.index("| capacity_id |")
assert idx_table < idx_beschreibung
# ---------------------------------------------------------------------------
# 2.2 Test empty-state scenario
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_empty_state_scenario(monkeypatch, tmp_path) -> None:
"""When all enrichment data is empty/None, (keine) placeholders appear."""
cap = _make_capacity()
fake_db = _FakeDB(
[cap],
description=None,
references=[],
certificates=[],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: fake_db)
srv = build_server(_make_config(tmp_path))
content, structured = await srv.call_tool(
"get_capacity_details", {"capacity_id": 1}
)
out = (structured or {}).get("result") or getattr(content[0], "text", "")
# All three sections show (keine) placeholders
assert "Beschreibung: (keine)" in out
assert "Referenzen: (keine)" in out
assert "Zertifizierungen: (keine)" in out
# Section headers still present
assert "## Beschreibung" in out
assert "## Referenzen" in out
assert "## Zertifizierungen" in out
# ---------------------------------------------------------------------------
# 2.3 Test capacity-not-found unchanged
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_capacity_not_found_unchanged(monkeypatch, tmp_path) -> None:
"""When capacity is not found, error message is returned without enrichment calls."""
fake_db = _FakeDB(
[], # No capacities → get_capacity_by_id returns None
description="Should not appear",
references=[{"partner_name": "X", "projects": "Y"}],
certificates=["Cert"],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: fake_db)
srv = build_server(_make_config(tmp_path))
content, structured = await srv.call_tool(
"get_capacity_details", {"capacity_id": 999}
)
out = (structured or {}).get("result") or getattr(content[0], "text", "")
# Returns the standard error message
assert out == "Capacity not found: 999"
# Enrichment data should NOT appear
assert "## Beschreibung" not in out
assert "## Referenzen" not in out
assert "## Zertifizierungen" not in out
assert "Should not appear" not in out
@@ -0,0 +1,446 @@
"""Property-based tests for the capacity details enrichment feature.
Uses Hypothesis to verify correctness properties across many random inputs.
"""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock, patch
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity, Task
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeDB:
"""Minimal fake DB client for property-based tests."""
def __init__(
self,
capacity: Capacity | None,
*,
description: str | None = None,
references: list[dict] | None = None,
certificates: list[str] | None = None,
) -> None:
self._capacity = capacity
self._description = description
self._references = references if references is not None else []
self._certificates = certificates if certificates is not None else []
# Track calls for Property 1
self.calls: dict[str, list] = {
"get_capacity_description": [],
"get_capacity_references": [],
"get_capacity_certificates": [],
}
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Cloud Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["AWS"]
def get_open_tasks(self, limit: int = 20) -> list[Task]:
return []
def get_task_by_id(self, task_id: str):
return None
def get_task_by_name(self, name: str):
return None
def get_all_capacities_with_competences(self) -> list[Capacity]:
return [self._capacity] if self._capacity else []
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
return [self._capacity] if self._capacity else []
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
if self._capacity and int(capacity_id) == self._capacity.id:
return self._capacity
return None
def get_capacity_description(self, capacity_id: int | str) -> str | None:
self.calls["get_capacity_description"].append(capacity_id)
return self._description
def get_capacity_references(self, capacity_id: int | str) -> list[dict]:
self.calls["get_capacity_references"].append(capacity_id)
return self._references
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
self.calls["get_capacity_certificates"].append(capacity_id)
return self._certificates
def _make_config(tmp_path) -> str:
"""Write a minimal config.toml and return its path."""
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
return str(cfg)
def _make_capacity(cap_id: int) -> Capacity:
return Capacity(
id=cap_id,
owner_name="Team Alpha",
role_name="Cloud Engineer",
role_level=None,
begin_date=date(2026, 1, 1),
end_date=date(2026, 6, 30),
competences=["AWS", "Terraform"],
)
# ---------------------------------------------------------------------------
# Property 1: All enrichment data is fetched for any capacity
# Feature: capacity-details-enrichment
# Validates: Requirements 1.1, 2.1, 3.1
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@settings(max_examples=100)
@given(capacity_id=st.integers(min_value=1, max_value=100_000))
async def test_all_enrichment_data_fetched(capacity_id: int, tmp_path_factory):
"""Property 1: All enrichment data is fetched for any capacity.
For any valid capacity ID that resolves to an existing capacity,
the tool SHALL invoke get_capacity_description, get_capacity_references,
and get_capacity_certificates with that capacity ID.
**Validates: Requirements 1.1, 2.1, 3.1**
"""
tmp_path = tmp_path_factory.mktemp("cfg")
cap = _make_capacity(capacity_id)
fake_db = _FakeDB(
cap,
description="Some description",
references=[{"partner_name": "P", "projects": "Proj"}],
certificates=["Cert A"],
)
import teamlandkarte_mcp.mcp_server as mod
with patch.object(mod, "create_db_client", return_value=fake_db):
srv = build_server(_make_config(tmp_path))
await srv.call_tool("get_capacity_details", {"capacity_id": capacity_id})
# Each enrichment method must be called exactly once with the correct ID
assert len(fake_db.calls["get_capacity_description"]) == 1
assert fake_db.calls["get_capacity_description"][0] == capacity_id
assert len(fake_db.calls["get_capacity_references"]) == 1
assert fake_db.calls["get_capacity_references"][0] == capacity_id
assert len(fake_db.calls["get_capacity_certificates"]) == 1
assert fake_db.calls["get_capacity_certificates"][0] == capacity_id
# ---------------------------------------------------------------------------
# Property 2: Non-empty data appears in output
# Feature: capacity-details-enrichment
# Validates: Requirements 1.2, 2.2, 3.2
# ---------------------------------------------------------------------------
# Strategies for generating test data
_description_st = st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=200,
).filter(lambda s: s.strip())
_reference_st = st.fixed_dictionaries({
"partner_name": st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=50,
).filter(lambda s: s.strip()),
"projects": st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=100,
).filter(lambda s: s.strip()),
})
_certificate_st = st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=100,
).filter(lambda s: s.strip())
@pytest.mark.asyncio
@settings(max_examples=100)
@given(
description=_description_st,
references=st.lists(_reference_st, min_size=1, max_size=5),
certificates=st.lists(_certificate_st, min_size=1, max_size=5),
)
async def test_nonempty_data_appears_in_output(
description: str,
references: list[dict],
certificates: list[str],
tmp_path_factory,
):
"""Property 2: Non-empty data appears in output.
For any capacity with a non-empty description, a non-empty list of
references, and a non-empty list of certificates, the formatted output
SHALL contain the description text, every reference's projects text,
and every certificate string.
**Validates: Requirements 1.2, 2.2, 3.2**
"""
tmp_path = tmp_path_factory.mktemp("cfg")
cap = _make_capacity(1)
fake_db = _FakeDB(
cap,
description=description,
references=references,
certificates=certificates,
)
import teamlandkarte_mcp.mcp_server as mod
with patch.object(mod, "create_db_client", return_value=fake_db):
srv = build_server(_make_config(tmp_path))
content, structured = await srv.call_tool(
"get_capacity_details", {"capacity_id": 1}
)
out = (structured or {}).get("result") or getattr(
content[0], "text", ""
)
# Description must appear in output
assert description in out, (
f"Description not found in output: {description!r}"
)
# Every reference's projects text must appear
for ref in references:
assert ref["projects"] in out, (
f"Reference projects not found: {ref['projects']!r}"
)
# Every certificate must appear
for cert in certificates:
assert cert in out, f"Certificate not found: {cert!r}"
# ---------------------------------------------------------------------------
# Property 3: Section ordering is fixed
# Feature: capacity-details-enrichment
# Validates: Requirements 4.1, 4.2
# ---------------------------------------------------------------------------
_optional_description_st = st.one_of(
st.none(),
st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=100,
).filter(lambda s: s.strip()),
)
_optional_references_st = st.lists(
st.fixed_dictionaries({
"partner_name": st.text(
alphabet=st.characters(
whitelist_categories=("L", "N"),
blacklist_characters="\x00",
),
min_size=0,
max_size=30,
),
"projects": st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=50,
).filter(lambda s: s.strip()),
}),
min_size=0,
max_size=5,
)
_optional_certificates_st = st.lists(
st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "P", "Z"),
blacklist_characters="\x00",
),
min_size=1,
max_size=50,
).filter(lambda s: s.strip()),
min_size=0,
max_size=5,
)
@pytest.mark.asyncio
@settings(max_examples=100, deadline=None)
@given(
description=_optional_description_st,
references=_optional_references_st,
certificates=_optional_certificates_st,
)
async def test_section_ordering_is_fixed(
description: str | None,
references: list[dict],
certificates: list[str],
tmp_path_factory,
):
"""Property 3: Section ordering is fixed.
For any capacity (regardless of which fields are empty or populated),
the output string SHALL contain the section markers in the order:
capacity table first, then "Beschreibung", then "Referenzen", then
"Zertifizierungen", then "Next steps" — and each section SHALL be
separated by at least one blank line.
**Validates: Requirements 4.1, 4.2**
"""
tmp_path = tmp_path_factory.mktemp("cfg")
cap = _make_capacity(1)
fake_db = _FakeDB(
cap,
description=description,
references=references,
certificates=certificates,
)
import teamlandkarte_mcp.mcp_server as mod
with patch.object(mod, "create_db_client", return_value=fake_db):
srv = build_server(_make_config(tmp_path))
content, structured = await srv.call_tool(
"get_capacity_details", {"capacity_id": 1}
)
out = (structured or {}).get("result") or getattr(
content[0], "text", ""
)
# All section headers must be present
assert "## Beschreibung" in out
assert "## Referenzen" in out
assert "## Zertifizierungen" in out
assert "## Next steps" in out
# Section ordering must be fixed
idx_beschreibung = out.index("## Beschreibung")
idx_referenzen = out.index("## Referenzen")
idx_zertifizierungen = out.index("## Zertifizierungen")
idx_next_steps = out.index("## Next steps")
assert idx_beschreibung < idx_referenzen, (
"Beschreibung must come before Referenzen"
)
assert idx_referenzen < idx_zertifizierungen, (
"Referenzen must come before Zertifizierungen"
)
assert idx_zertifizierungen < idx_next_steps, (
"Zertifizierungen must come before Next steps"
)
# Capacity table must come before all enrichment sections
assert "| capacity_id |" in out
idx_table = out.index("| capacity_id |")
assert idx_table < idx_beschreibung, (
"Capacity table must come before Beschreibung"
)
# Each section must be separated by at least one blank line
# A blank line means two consecutive newlines (\n\n)
between_table_and_beschreibung = out[idx_table:idx_beschreibung]
assert "\n\n" in between_table_and_beschreibung, (
"Blank line required between table and Beschreibung"
)
between_beschreibung_and_referenzen = out[idx_beschreibung:idx_referenzen]
assert "\n\n" in between_beschreibung_and_referenzen, (
"Blank line required between Beschreibung and Referenzen"
)
between_referenzen_and_zertifizierungen = out[
idx_referenzen:idx_zertifizierungen
]
assert "\n\n" in between_referenzen_and_zertifizierungen, (
"Blank line required between Referenzen and Zertifizierungen"
)
between_zertifizierungen_and_next = out[idx_zertifizierungen:idx_next_steps]
assert "\n\n" in between_zertifizierungen_and_next, (
"Blank line required between Zertifizierungen and Next steps"
)
@@ -0,0 +1,147 @@
# Bugfix: llm-competence-inference, Property 1: Fault Condition
# Kompetenz-Inferenz liefert stets leere Liste (Bug-Bestätigung)
"""Property-based exploration test for the competence inference bug.
This test encodes the EXPECTED (correct) behaviour: given a non-empty task
text and a non-empty competence list in the DB, `infer_competences` should
return a non-empty list of up to 10 (name, confidence) tuples where each
name is in the DB competence list and confidence is in [0.0, 1.0].
On UNFIXED code this test is EXPECTED TO FAIL — the failure confirms the bug
exists (infer_competences either doesn't exist or always returns []).
**Validates: Requirements 1.1, 1.2, 1.3, 2.1, 2.2, 2.3**
"""
from __future__ import annotations
import json
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Non-empty task text (printable characters, min 1 char)
_task_text_strategy = st.text(min_size=1, max_size=200).filter(lambda s: s.strip())
# Non-empty competence list (each competence is a non-empty string)
_competence_list_strategy = st.lists(
st.text(min_size=1, max_size=50).filter(lambda s: s.strip()),
min_size=1,
max_size=50,
unique=True,
)
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeDB:
"""Stub DBClient that returns a configurable competence list."""
def __init__(self, competences: list[str]) -> None:
self._competences = competences
def get_all_competence_names(self) -> list[str]:
return self._competences
def get_all_role_names(self) -> list[str]:
return ["Software Engineer"]
class _FakeLLMClient:
"""Stub AzureOpenAIClient that returns valid JSON with competences.
Simulates a working LLM that picks up to 5 competences from the
provided list (via the user prompt) and assigns confidence scores.
"""
def __init__(self, competences: list[str]) -> None:
self._competences = competences
async def chat_completion(self, system: str, user: str) -> str:
# Pick up to 5 competences from the list and return valid JSON
selected = self._competences[:5]
result = {
"competences": [
{"name": name, "confidence": round(0.6 + i * 0.05, 2)}
for i, name in enumerate(selected)
]
}
return json.dumps(result)
# ---------------------------------------------------------------------------
# Property Test
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
task_text=_task_text_strategy,
competences=_competence_list_strategy,
)
@pytest.mark.asyncio
async def test_infer_competences_returns_nonempty_for_valid_input(
task_text: str,
competences: list[str],
) -> None:
"""Property 1 (Fault Condition): For any non-empty task text and non-empty
competence list in the DB, infer_competences MUST return a non-empty list
of up to 10 tuples (name, confidence) where each name is in the DB
competence list and confidence is in [0.0, 1.0].
On unfixed code this WILL FAIL because:
- The method `infer_competences` does not exist on VocabularyCache, OR
- It always returns []
"""
db = _FakeDB(competences=competences)
client = _FakeLLMClient(competences=competences)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
# The method must exist and be callable
assert hasattr(cache, "infer_competences"), (
"VocabularyCache has no 'infer_competences' method — "
"the competence inference is not implemented"
)
result = await cache.infer_competences(task_text=task_text)
# Must return a non-empty list
assert isinstance(result, list), (
f"Expected list, got {type(result).__name__}"
)
assert len(result) > 0, (
f"infer_competences('{task_text[:50]}...') returned empty list [] "
f"instead of competences — bug confirmed: no LLM inference happening"
)
# Must return at most 10 tuples
assert len(result) <= 10, (
f"Expected at most 10 competences, got {len(result)}"
)
# Each element must be a tuple (name, confidence)
for item in result:
assert isinstance(item, tuple) and len(item) == 2, (
f"Expected tuple (name, confidence), got {item!r}"
)
name, confidence = item
assert name in competences, (
f"Returned competence '{name}' not in DB competence list"
)
assert isinstance(confidence, float), (
f"Expected float confidence, got {type(confidence).__name__}"
)
assert 0.0 <= confidence <= 1.0, (
f"Confidence {confidence} out of range [0.0, 1.0]"
)
@@ -0,0 +1,251 @@
# Bugfix: llm-competence-inference, Property 2: Preservation
# Rollen-Inferenz und Leer-Eingaben unverändert
"""Property-based preservation tests for the competence inference bugfix.
These tests verify that EXISTING behaviour is preserved both before and after
the fix:
1. `infer_primary_role` continues to return a valid (role, confidence) tuple
for non-empty task texts (or None on error).
2. Empty task text yields [] from `infer_competences` (if the method exists).
3. Empty competence list in DB yields [] from `infer_competences` (if exists).
On UNFIXED code these tests are EXPECTED TO PASS — they confirm baseline
behaviour that must not regress after the fix is applied.
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
"""
from __future__ import annotations
import json
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Non-empty task text (printable ASCII, min 1 char after strip)
_nonempty_task_text = st.text(
alphabet=st.characters(
whitelist_categories=("L", "N", "Zs"),
max_codepoint=127,
),
min_size=1,
max_size=150,
).filter(lambda s: s.strip())
# Empty or whitespace-only task text
_empty_task_text = st.one_of(
st.just(""),
st.from_regex(r"^\s*$", fullmatch=True),
)
# Non-empty competence list
_competence_list = st.lists(
st.text(
alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127),
min_size=2,
max_size=30,
).filter(lambda s: s.strip()),
min_size=1,
max_size=30,
unique=True,
)
# Non-empty role list
_role_list = st.lists(
st.text(
alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127),
min_size=3,
max_size=30,
).filter(lambda s: s.strip()),
min_size=1,
max_size=10,
unique=True,
)
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeDB:
"""Stub DBClient with configurable roles and competences."""
def __init__(
self,
roles: list[str] | None = None,
competences: list[str] | None = None,
) -> None:
self._roles = roles or ["Software Engineer"]
self._competences = competences or []
def get_all_role_names(self) -> list[str]:
return self._roles
def get_all_competence_names(self) -> list[str]:
return self._competences
class _FakeRoleLLMClient:
"""Stub AzureOpenAIClient that returns a valid role from the DB list.
Picks the first role from the list provided at construction time.
"""
def __init__(self, role: str, confidence: float = 0.85) -> None:
self._role = role
self._confidence = confidence
async def chat_completion(self, system: str, user: str) -> str:
return json.dumps({"role": self._role, "confidence": self._confidence})
class _FakeCompetenceLLMClient:
"""Stub AzureOpenAIClient that returns valid competence JSON."""
def __init__(self, competences: list[str] | None = None) -> None:
self._competences = competences or []
async def chat_completion(self, system: str, user: str) -> str:
selected = self._competences[:3]
result = {
"competences": [
{"name": name, "confidence": 0.8} for name in selected
]
}
return json.dumps(result)
# ---------------------------------------------------------------------------
# Property-Based Test 1: infer_primary_role preservation
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
task_text=_nonempty_task_text,
roles=_role_list,
role_index=st.data(),
)
@pytest.mark.asyncio
async def test_infer_primary_role_returns_valid_tuple_for_nonempty_text(
task_text: str,
roles: list[str],
role_index: st.DataObject,
) -> None:
"""Property 2.1 (Preservation): For all non-empty task texts,
`infer_primary_role` returns a tuple (role_name, confidence) where
role_name is in the DB role list and confidence is in [0.0, 1.0],
or None on failure.
This verifies Requirement 3.1 and 3.5: infer_primary_role must continue
to select exactly one role from the role list with a confidence score.
**Validates: Requirements 3.1, 3.4, 3.5**
"""
idx = role_index.draw(st.integers(min_value=0, max_value=len(roles) - 1))
chosen_role = roles[idx]
db = _FakeDB(roles=roles)
client = _FakeRoleLLMClient(role=chosen_role, confidence=0.85)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_primary_role(task_text=task_text)
# Must return a valid tuple or None (None only on error/unknown role)
assert result is not None, (
f"infer_primary_role returned None for valid role '{chosen_role}' "
f"in list {roles}"
)
role_name, confidence = result
assert role_name in roles, (
f"Returned role '{role_name}' not in DB role list"
)
assert isinstance(confidence, float), (
f"Expected float confidence, got {type(confidence).__name__}"
)
assert 0.0 <= confidence <= 1.0, (
f"Confidence {confidence} out of range [0.0, 1.0]"
)
# ---------------------------------------------------------------------------
# Property-Based Test 2: Empty task text → infer_competences returns []
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(task_text=_empty_task_text)
@pytest.mark.asyncio
async def test_infer_competences_returns_empty_for_empty_text(
task_text: str,
) -> None:
"""Property 2.2 (Preservation): For all empty/whitespace-only task texts,
`infer_competences` returns [].
If the method does not exist yet (unfixed code), the test passes trivially
because the preservation property is satisfied — there is no method that
could produce incorrect results for empty input.
**Validates: Requirements 3.2, 3.3**
"""
competences = ["Python", "Java", "TypeScript", "React", "Docker"]
db = _FakeDB(roles=["Engineer"], competences=competences)
client = _FakeCompetenceLLMClient(competences=competences)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
# If infer_competences doesn't exist yet, preservation is trivially satisfied
if not hasattr(cache, "infer_competences"):
return
result = await cache.infer_competences(task_text=task_text)
assert result == [], (
f"infer_competences('{task_text!r}') should return [] for "
f"empty/whitespace text, got {result!r}"
)
# ---------------------------------------------------------------------------
# Property-Based Test 3: Empty competence list in DB → infer_competences []
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(task_text=_nonempty_task_text)
@pytest.mark.asyncio
async def test_infer_competences_returns_empty_for_empty_db_competences(
task_text: str,
) -> None:
"""Property 2.3 (Preservation): For an empty competence list in the DB,
`infer_competences` returns [] regardless of task text.
If the method does not exist yet (unfixed code), the test passes trivially
because the preservation property is satisfied — there is no method that
could produce incorrect results for empty DB competences.
**Validates: Requirements 3.2, 3.3**
"""
db = _FakeDB(roles=["Engineer"], competences=[]) # Empty competence list
client = _FakeCompetenceLLMClient(competences=[])
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
# If infer_competences doesn't exist yet, preservation is trivially satisfied
if not hasattr(cache, "infer_competences"):
return
result = await cache.infer_competences(task_text=task_text)
assert result == [], (
f"infer_competences('{task_text[:50]}...') should return [] when "
f"DB has no competences, got {result!r}"
)
@@ -0,0 +1,118 @@
"""Unit tests for ``MatchingConfig.default_method`` validation in ``load_config``.
Covers the three cases from spec task 5.3:
* ``default_method`` missing -> default ``"score"``.
* ``default_method = "llm_fulltext"`` -> accepted as-is.
* ``default_method = "irgendwas"`` -> ``ConfigError`` with helpful message.
Validates: Requirements 12.3, 12.4
"""
from __future__ import annotations
import pytest
from teamlandkarte_mcp.config import ConfigError, load_config
def _write_config(tmp_path, *, default_method_line: str = ""):
"""Write a minimal valid ``config.toml`` for ``load_config`` tests.
``default_method_line`` is inserted into the ``[matching]`` section as a
raw TOML line (e.g. ``'default_method = "llm_fulltext"'``) or left empty
to omit the key entirely.
"""
content = f"""\
[database]
backend = "trino"
host = "trino.example"
port = 443
http_scheme = "https"
verify_ssl = true
catalog = "hive"
schema = "tier1_open_lake"
connect_timeout = 10
pool_size = 4
[matching]
competence_weight = 0.8
role_weight = 0.2
require_confirmation = true
{default_method_line}
[matching.thresholds]
top = 0.8
good = 0.65
partial = 0.5
low = 0.3
[matching.fuzzy]
min_similarity = 0.7
[cache]
db_ttl_hours = 12
search_ttl_minutes = 60
max_size = 100
[azure_openai]
endpoint = "https://example.openai.azure.com"
api_version = "2024-02-15-preview"
chat_deployment = "gpt-4"
show_costs_in_output = false
"""
p = tmp_path / "config.toml"
p.write_text(content)
return p
@pytest.fixture(autouse=True)
def _set_env(monkeypatch):
"""Provide the credentials required by ``load_config``."""
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
def test_default_method_missing_falls_back_to_score(tmp_path):
path = _write_config(tmp_path)
cfg = load_config(path)
assert cfg.matching.default_method == "score"
def test_default_method_llm_fulltext_is_accepted(tmp_path):
path = _write_config(
tmp_path, default_method_line='default_method = "llm_fulltext"'
)
cfg = load_config(path)
assert cfg.matching.default_method == "llm_fulltext"
def test_default_method_score_is_accepted_explicit(tmp_path):
path = _write_config(
tmp_path, default_method_line='default_method = "score"'
)
cfg = load_config(path)
assert cfg.matching.default_method == "score"
def test_default_method_case_insensitive(tmp_path):
path = _write_config(
tmp_path, default_method_line='default_method = "LLM_FULLTEXT"'
)
cfg = load_config(path)
assert cfg.matching.default_method == "llm_fulltext"
def test_default_method_invalid_raises_config_error(tmp_path):
path = _write_config(
tmp_path, default_method_line='default_method = "irgendwas"'
)
with pytest.raises(ConfigError) as exc:
load_config(path)
msg = str(exc.value)
assert "default_method" in msg
assert "score" in msg
assert "llm_fulltext" in msg
@@ -0,0 +1,249 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import date
from typing import Any
from teamlandkarte_mcp.mcp_server import build_server
@dataclass
class FakeDBClient:
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Cloud Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["AWS"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self): # pragma: no cover
return []
class FakeMatcher:
async def match(self, capacities, requirements): # pragma: no cover
raise AssertionError("match should not be called when gate blocks")
def _result_to_text(result: Any) -> str:
# FastMCP.call_tool returns (content, structured) in this project.
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(structured.get("result"), str):
return structured["result"]
if isinstance(content, list) and content:
# Typically TextContent(type='text', text='...')
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
if isinstance(result, str):
return result
return str(result)
def _call_tool(
srv: Any,
name: str,
arguments: dict[str, Any] | None = None,
) -> str:
if arguments is None:
arguments = {}
coro = srv.call_tool(name, arguments)
return _result_to_text(asyncio.run(coro))
def _get_tools(srv: Any) -> dict[str, Any]:
"""Return a mapping of tool-name -> callable.
The MCP library's internal structure can vary across versions.
We try a few known locations and fall back to asking the server object.
"""
for attr in ("_tools", "tools"):
tools = getattr(srv, attr, None)
if isinstance(tools, dict):
return tools
# Some MCP implementations expose tools via a method.
for method_name in ("get_tools", "list_tools"):
maybe = getattr(srv, method_name, None)
if callable(maybe):
tools = maybe()
if isinstance(tools, dict):
return tools
raise AssertionError("Unable to locate tool registry on server instance")
def test_find_matching_capacities_requires_confirmation(
monkeypatch,
tmp_path,
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=true
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
# Patch DB client creation inside server builder.
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient())
srv = build_server(str(cfg))
msg = _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "Cloud Engineer",
"competences": ["AWS"],
"date_start": str(date(2025, 1, 1)),
"date_end": str(date(2025, 1, 31)),
},
)
assert "must be confirmed" in msg
def test_find_matching_capacities_auto_skips_when_disabled(
monkeypatch, tmp_path
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient())
srv = build_server(str(cfg))
# If auto-skip is enabled, the gate should not block. We don't assert the
# full output (it depends on matcher/cache); just that it isn't blocked.
# The call will still fail later because capacities list is empty, but it
# must not fail with the confirmation message.
msg = _call_tool(
srv,
"find_matching_capacities",
{"role_name": "Cloud Engineer", "competences": ["AWS"]},
)
assert "must be confirmed" not in msg
def test_confirm_requires_review_after_latest_requirement_update(
monkeypatch, tmp_path
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=true
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient())
srv = build_server(str(cfg))
# Wrong order: review before any requirements exist.
msg = _call_tool(srv, "show_pending_requirements")
assert "No pending requirements" in msg
# Collect requirements afterwards.
_call_tool(
srv,
"collect_structured_requirement_data",
{"role_name": "Backend Developer", "competences": ["Python"]},
)
# Confirm must still be blocked: latest requirements were not reviewed.
msg = _call_tool(srv, "confirm_requirements", {"confirm": True})
assert "User confirmation has not been requested" in msg
# Correct order: review after collecting, then confirm.
_call_tool(srv, "show_pending_requirements")
msg = _call_tool(srv, "confirm_requirements", {"confirm": True})
assert "Requirements confirmed" in msg
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import date
from teamlandkarte_mcp.utils.dates import availability_overlaps
def test_availability_overlaps_open_ended_capacity_end() -> None:
cap_begin = date(2025, 1, 1)
cap_end = None
assert availability_overlaps(
cap_begin, cap_end, date(2025, 2, 1), date(2025, 2, 28)
)
assert availability_overlaps(cap_begin, cap_end, None, date(2025, 2, 28))
assert availability_overlaps(cap_begin, cap_end, date(2024, 12, 1), None)
def test_availability_overlaps_non_overlapping() -> None:
assert not availability_overlaps(
date(2025, 1, 1),
date(2025, 1, 31),
date(2025, 2, 1),
date(2025, 2, 28),
)
def test_availability_overlaps_inclusive_edges() -> None:
assert availability_overlaps(
date(2025, 1, 1),
date(2025, 1, 31),
date(2025, 1, 31),
date(2025, 2, 1),
)
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
import pytest
from teamlandkarte_mcp.matching.task_helpers import (
TaskHelpersDeprecatedError,
extract_requirements_from_task,
)
class MissingDB:
"""DB stub that simulates an unavailable database."""
def get_task_by_id(self, task_id: str):
raise RuntimeError("db unavailable")
def get_task_by_name(self, name: str):
raise RuntimeError("db unavailable")
class NoopAnalyzer:
async def extract_requirements(self, description: str): # pragma: no cover
raise AssertionError("should not be called")
def test_extract_requirements_from_task_is_deprecated() -> None:
db = MissingDB()
analyzer = NoopAnalyzer()
with pytest.raises(TaskHelpersDeprecatedError):
asyncio.run(
extract_requirements_from_task(
db=db,
analyzer=analyzer,
task_id="t",
)
)
@@ -0,0 +1,340 @@
from __future__ import annotations
from dataclasses import asdict
from datetime import date
from teamlandkarte_mcp.cache.search_cache import SearchCache
from teamlandkarte_mcp.models import Capacity, Team, TeamCompetence, TeamReference
from teamlandkarte_mcp.utils.dates import availability_overlaps
def _mk_capacity(
*,
cid: int,
role: str,
comps: list[str],
begin: date | None = None,
end: date | None = None,
) -> dict:
cap = Capacity(
id=cid,
owner_name=f"O{cid}",
role_name=role,
role_level=None,
begin_date=begin,
end_date=end,
competences=comps,
)
return asdict(cap)
def test_filter_semantics_role_only() -> None:
"""Role-only filtering should keep matching roles and drop others."""
cache = SearchCache(ttl_minutes=60, max_size=10)
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "Backend Developer", "competences": ["Python"]},
results={
"Top": [
_mk_capacity(cid=1, role="Backend Developer", comps=["Python"]),
_mk_capacity(cid=2, role="Data Engineer", comps=["Python"]),
],
"Good": [],
"Partial": [],
"Low": [],
},
)
entry = cache.get(search_id)
assert entry is not None
all_items = []
for cat in ["Top", "Good", "Partial", "Low"]:
all_items.extend(entry.results.get(cat, []))
role_filter = "backend developer"
kept = [i for i in all_items if role_filter in (i.get("role_name") or "").lower()]
# Re-categorization is Scorer responsibility; here we just assert filtering.
assert [k["id"] for k in kept] == [1]
def test_filter_semantics_competence_only_all_required() -> None:
"""Competence filter requires all requested competences to be present."""
required = ["Python", "FastAPI"]
items = [
_mk_capacity(cid=1, role="Backend", comps=["Python", "FastAPI"]),
_mk_capacity(cid=2, role="Backend", comps=["Python"]),
]
def passes(item: dict) -> bool:
comps = {c.lower() for c in (item.get("competences") or [])}
return all(r.lower() in comps for r in required)
kept = [i for i in items if passes(i)]
assert [k["id"] for k in kept] == [1]
def test_filter_semantics_availability_open_ended_end_date() -> None:
"""Availability overlap treats missing end_date as open-ended."""
cap_begin = date(2025, 1, 1)
cap_end = None
assert availability_overlaps(
cap_begin, cap_end, date(2025, 2, 1), date(2025, 2, 28)
)
assert not availability_overlaps(
cap_begin, cap_end, date(2024, 1, 1), date(2024, 1, 31)
)
# ---------------------------------------------------------------------------
# Team-search filter coverage (Profile_Type="team")
# ---------------------------------------------------------------------------
#
# Team searches re-purpose ``role_filter`` against ``team.focus_name``
# (rather than ``cap.role_name``) and apply ``competence_filter``
# against the team's competence-name list extracted from
# ``competences[*].name``. Availability filters are ignored for team
# searches. The tests below verify these semantics in pure form for
# both ``matching_method`` flavours so the routing/filter surface is
# covered for both ``Profile_Type`` values.
#
# _Requirements: 6.7, 8.5, 8.6, 12.7_
def _mk_team_dict(
*,
tid: str,
focus_name: str,
competences: list[tuple[str, bool]],
matching_method: str,
) -> dict:
"""Build a cached team item mirroring the persisted shape used by
``find_matching_teams``.
The shape matches what ``filter_search_results`` operates on
(``competences = [{"name": ..., "top_competency": ...}]``) and
includes either score fields or ``rationale`` based on
``matching_method``.
"""
team = Team(
team_id=tid,
ouid=f"ou-{tid}",
team_name=f"Team {tid}",
focus_name=focus_name,
about_us="",
offerings="",
interests="",
competences=[TeamCompetence(name=n, top_competency=t)
for n, t in competences],
references=[TeamReference(partner_name="", projects="P")],
)
base = asdict(team)
base["category"] = "Top"
if matching_method == "score":
base.update(
competence_score=1.0,
role_score=1.0,
overall_score=1.0,
)
else:
base["rationale"] = "ok"
return base
def test_team_search_role_filter_matches_focus_name_score_mode() -> None:
"""``role_filter`` for ``team_search`` matches against
``focus_name`` (Anforderung 8.5) - score-mode payload."""
items = [
_mk_team_dict(
tid="t1",
focus_name="Backend Developer",
competences=[("python", True)],
matching_method="score",
),
_mk_team_dict(
tid="t2",
focus_name="Frontend Developer",
competences=[("javascript", True)],
matching_method="score",
),
_mk_team_dict(
tid="t3",
focus_name="Data Engineer",
competences=[("python", False)],
matching_method="score",
),
]
role_filter = "backend"
kept = [
it for it in items
if role_filter in (it.get("focus_name") or "").lower()
]
assert [k["team_id"] for k in kept] == ["t1"]
def test_team_search_role_filter_matches_focus_name_llm_mode() -> None:
"""Same role-filter semantics in the LLM-fulltext mode payload
shape (``rationale`` instead of score fields)."""
items = [
_mk_team_dict(
tid="t1",
focus_name="Backend Developer",
competences=[("python", True)],
matching_method="llm_fulltext",
),
_mk_team_dict(
tid="t2",
focus_name="Frontend Developer",
competences=[("javascript", True)],
matching_method="llm_fulltext",
),
]
role_filter = "frontend"
kept = [
it for it in items
if role_filter in (it.get("focus_name") or "").lower()
]
assert [k["team_id"] for k in kept] == ["t2"]
# Each kept item carries the LLM-mode rationale field used by the
# ``Begründung`` column.
assert all("rationale" in k for k in kept)
def test_team_search_competence_filter_extracts_names_from_records() -> None:
"""``competence_filter`` for ``team_search`` operates on the names
extracted from the cached competence records, not on raw strings.
"""
required = ["python"]
items = [
_mk_team_dict(
tid="t1",
focus_name="Backend Developer",
competences=[("python", True), ("fastapi", False)],
matching_method="score",
),
_mk_team_dict(
tid="t2",
focus_name="Frontend Developer",
competences=[("javascript", True)],
matching_method="score",
),
]
def passes(item: dict) -> bool:
names = {
(c.get("name") or "").lower()
for c in (item.get("competences") or [])
}
return all(r.lower() in names for r in required)
kept = [it for it in items if passes(it)]
assert [k["team_id"] for k in kept] == ["t1"]
def test_team_search_top_competency_filter_suffix() -> None:
"""``competence_filter`` values with the ``(Top)`` suffix restrict
the match to top competences (Anforderung 9.7)."""
items = [
_mk_team_dict(
tid="t1",
focus_name="Backend Developer",
# python is a top competence here
competences=[("python", True)],
matching_method="llm_fulltext",
),
_mk_team_dict(
tid="t2",
focus_name="Data Engineer",
# python is present but NOT a top competence
competences=[("python", False)],
matching_method="llm_fulltext",
),
]
# Caller asks for a Top-only python match (mirrors the production
# rule: filter values with ``(Top)`` suffix are stripped and
# require ``top_competency=True`` on the matched record).
raw_filter_value = "python (Top)"
target = raw_filter_value.removesuffix("(Top)").strip().lower()
def passes_top_only(item: dict) -> bool:
for c in item.get("competences") or []:
name = (c.get("name") or "").strip().lower()
if name == target and bool(c.get("top_competency")):
return True
return False
kept = [it for it in items if passes_top_only(it)]
assert [k["team_id"] for k in kept] == ["t1"]
def test_team_search_availability_filter_is_ignored() -> None:
"""Availability filters do not change the item set for
``team_search`` payloads (Anforderung 6.7 / 8.6).
The cached ``team_search`` items have no availability fields. The
production filter path therefore short-circuits and ignores
``availability_date_*`` and ``is_fully_available``. We mirror that
invariant here in pure form: applying any availability predicate
onto the team item set must never reduce the set.
"""
items = [
_mk_team_dict(
tid="t1",
focus_name="Backend Developer",
competences=[("python", True)],
matching_method="score",
),
_mk_team_dict(
tid="t2",
focus_name="Frontend Developer",
competences=[("javascript", True)],
matching_method="score",
),
]
# No item carries begin_date/end_date so any availability predicate
# over those fields would unduly drop everything. The team-search
# path must therefore ignore the predicate entirely.
cache = SearchCache(ttl_minutes=60, max_size=10)
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "Backend Developer",
"competences": ["python"]},
results={
"search_type": "team_search",
"matching_method": "score",
"reference": {
"availability_date_start": None,
"availability_date_end": None,
},
"summary": {"Top": len(items)},
"by_category": {"Top": items, "Good": [], "Partial": [],
"Low": [], "Irrelevant": []},
},
)
entry = cache.get(search_id)
assert entry is not None
cached_items = entry.results["by_category"]["Top"]
# Property: filtering availability against absent fields would
# reject every item. The team-search routing path therefore must
# skip the predicate, which we model here by asserting that the
# cached items still all match a "no availability filter applied"
# short-circuit.
def availability_short_circuit(_item: dict) -> bool:
# team_search → ignore availability filters entirely.
return True
kept = [it for it in cached_items if availability_short_circuit(it)]
assert [k["team_id"] for k in kept] == ["t1", "t2"]
@@ -0,0 +1,112 @@
# Feature: llm-fulltext-matching, Property 7: Tabellen-Rationale ist gültiges Markdown und längenbegrenzt
"""Property-based tests for ``_format_rationale_for_table``.
For any input string ``R``, the helper
:func:`teamlandkarte_mcp.mcp_server._format_rationale_for_table` must:
1. Produce output of at most 280 characters.
2. Never emit a literal pipe ``|`` (so the Markdown table stays valid).
3. Never emit ``\\n`` or ``\\r`` (whitespace is collapsed to spaces).
4. Match the canonical normalized form when the normalized form fits in
280 characters; otherwise end with the ellipsis character ``…``
(U+2026) and equal ``normalized[:279].rstrip() + ""``.
The "normalized" form is defined as
``" ".join(R.replace("|", "/").replace("\\r", " ").replace("\\n", " ").split())``.
**Validates: Requirements 8.4, 8.5**
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.mcp_server import _format_rationale_for_table
# Surrogate code points (Cs) are intentionally excluded; Hypothesis can
# otherwise produce lone surrogates that would break the assertions on
# string equality. Control characters (``\\n``, ``\\r``, ``\\t``...) are
# kept on purpose: the helper is supposed to handle them explicitly.
_TEXT_STRATEGY = st.text(
alphabet=st.characters(blacklist_categories=("Cs",)),
max_size=600,
)
def _normalize(text: str) -> str:
"""Replicate the helper's normalization (without truncation)."""
cleaned = text.replace("|", "/").replace("\r", " ").replace("\n", " ")
return " ".join(cleaned.split())
@settings(max_examples=100, deadline=None)
@given(text=_TEXT_STRATEGY)
def test_format_rationale_is_bounded_safe_and_canonical(text: str) -> None:
"""Property 7: bounded length, no pipes/newlines, deterministic shape."""
out = _format_rationale_for_table(text)
# 1. Length is bounded by 280.
assert len(out) <= 280, (
f"output longer than 280 chars: {len(out)} for input {text!r}"
)
# 2. Pipe characters are translated.
assert "|" not in out, f"pipe leaked into output: {out!r}"
# 3. Newlines/carriage returns are translated.
assert "\n" not in out, f"newline leaked into output: {out!r}"
assert "\r" not in out, f"carriage return leaked into output: {out!r}"
# 4. Match canonical form, with truncation behaviour.
normalized = _normalize(text)
if len(normalized) <= 280:
assert out == normalized, (
f"expected verbatim normalized form, got {out!r} "
f"(normalized={normalized!r})"
)
# Note: ``out`` may legitimately end with ``…`` if the input itself
# contained an ellipsis. The contract is that the helper does not
# *append* an ellipsis when the input fits, which is implied by
# ``out == normalized``.
else:
expected = normalized[:279].rstrip() + "\u2026"
assert out == expected, (
f"expected truncated form {expected!r}, got {out!r}"
)
assert out.endswith("\u2026"), (
"truncated output must end with the ellipsis character"
)
@settings(max_examples=100, deadline=None)
@given(
text=st.text(
alphabet=st.characters(
blacklist_categories=("Cs",),
blacklist_characters="|\r\n",
),
min_size=281,
max_size=600,
)
)
def test_format_rationale_truncates_when_normalized_too_long(text: str) -> None:
"""Property 7 (forced truncation): outputs longer than 280 are shortened.
Generates text without pipes/newlines so the normalized form preserves
most of the input length and almost certainly exceeds 280 characters
(after whitespace collapsing). When that happens, the output must end
with ``…`` and equal ``normalized[:279].rstrip() + ""``.
"""
out = _format_rationale_for_table(text)
normalized = _normalize(text)
assert len(out) <= 280
if len(normalized) > 280:
assert out.endswith("\u2026")
assert out == normalized[:279].rstrip() + "\u2026"
else:
# Whitespace collapse may rarely shrink below 281; in that case
# the helper returns the verbatim normalized form.
assert out == normalized
@@ -0,0 +1,17 @@
from __future__ import annotations
import difflib
def _ratio(a: str, b: str) -> int:
return int(difflib.SequenceMatcher(a=a, b=b).ratio() * 100)
def test_similarity_thresholds_behave_monotonically() -> None:
"""Similarity ratios should increase for more similar strings."""
a = "backend developer"
b1 = "backend dev"
b2 = "data engineer"
assert _ratio(a, b1) > _ratio(a, b2)
@@ -0,0 +1,198 @@
# Unit tests for VocabularyCache.infer_competences with mocked LLM client.
#
# **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
from __future__ import annotations
import json
import logging
import pytest
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
# ---------------------------------------------------------------------------
# Fakes / Stubs
# ---------------------------------------------------------------------------
class FakeDB:
"""Stub DBClient returning a configurable competence list."""
def __init__(self, competences: list[str] | None = None) -> None:
self._competences = competences if competences is not None else []
def get_all_competence_names(self) -> list[str]:
return self._competences
def get_all_role_names(self) -> list[str]:
return ["Software Engineer"]
class FakeLLMClient:
"""Stub AzureOpenAIClient that returns a pre-configured response."""
def __init__(self, response: str | Exception) -> None:
self._response = response
async def chat_completion(self, system: str, user: str) -> str:
if isinstance(self._response, Exception):
raise self._response
return self._response
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_valid_json_response_returns_correct_tuples() -> None:
"""Valid JSON response from LLM → correct list of (name, confidence) tuples."""
competences = ["Python", "TypeScript", "React", "Docker"]
llm_response = json.dumps(
{
"competences": [
{"name": "Python", "confidence": 0.95},
{"name": "React", "confidence": 0.8},
]
}
)
db = FakeDB(competences=competences)
client = FakeLLMClient(response=llm_response)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_competences(task_text="Build a Python backend")
assert result == [("Python", 0.95), ("React", 0.8)]
@pytest.mark.asyncio
async def test_empty_task_text_returns_empty_list() -> None:
"""Empty task text → []."""
competences = ["Python", "TypeScript"]
llm_response = json.dumps({"competences": [{"name": "Python", "confidence": 0.9}]})
db = FakeDB(competences=competences)
client = FakeLLMClient(response=llm_response)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
assert await cache.infer_competences(task_text="") == []
assert await cache.infer_competences(task_text=" ") == []
@pytest.mark.asyncio
async def test_empty_competence_list_in_db_returns_empty_list() -> None:
"""Empty competence list in DB → []."""
llm_response = json.dumps({"competences": [{"name": "Python", "confidence": 0.9}]})
db = FakeDB(competences=[])
client = FakeLLMClient(response=llm_response)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_competences(task_text="Build a Python backend")
assert result == []
@pytest.mark.asyncio
async def test_llm_error_returns_empty_list_and_logs_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""LLM error (Exception) → [] + warning logged."""
competences = ["Python", "TypeScript"]
db = FakeDB(competences=competences)
client = FakeLLMClient(response=RuntimeError("API timeout"))
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
result = await cache.infer_competences(task_text="Build a Python backend")
assert result == []
assert any("LLM call failed" in record.message for record in caplog.records)
@pytest.mark.asyncio
async def test_invalid_json_response_returns_empty_list(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Invalid JSON response → [] (json.loads raises, caught by except)."""
competences = ["Python", "TypeScript"]
db = FakeDB(competences=competences)
client = FakeLLMClient(response="this is not valid json {{{")
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
with caplog.at_level(logging.WARNING):
result = await cache.infer_competences(task_text="Build a Python backend")
assert result == []
assert any("LLM call failed" in record.message for record in caplog.records)
@pytest.mark.asyncio
async def test_unknown_competence_names_are_filtered_out() -> None:
"""Competence names not in DB → filtered out."""
competences = ["Python", "TypeScript", "React"]
llm_response = json.dumps(
{
"competences": [
{"name": "Python", "confidence": 0.9},
{"name": "Rust", "confidence": 0.85}, # not in DB
{"name": "Go", "confidence": 0.7}, # not in DB
{"name": "React", "confidence": 0.6},
]
}
)
db = FakeDB(competences=competences)
client = FakeLLMClient(response=llm_response)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_competences(task_text="Build a backend service")
assert result == [("Python", 0.9), ("React", 0.6)]
@pytest.mark.asyncio
async def test_max_competences_limited_to_10() -> None:
"""Result is limited to max_competences (default 10)."""
# Create 15 competences and have the LLM return all of them
competences = [f"Competence_{i}" for i in range(15)]
llm_response = json.dumps(
{
"competences": [
{"name": name, "confidence": round(0.5 + i * 0.03, 2)}
for i, name in enumerate(competences)
]
}
)
db = FakeDB(competences=competences)
client = FakeLLMClient(response=llm_response)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_competences(task_text="A complex task")
assert len(result) == 10
# All returned names must be in the DB list
for name, confidence in result:
assert name in competences
assert 0.0 <= confidence <= 1.0
@pytest.mark.asyncio
async def test_confidence_clamped_to_valid_range() -> None:
"""Confidence values outside [0.0, 1.0] are clamped."""
competences = ["Python", "TypeScript"]
llm_response = json.dumps(
{
"competences": [
{"name": "Python", "confidence": 1.5}, # above 1.0
{"name": "TypeScript", "confidence": -0.3}, # below 0.0
]
}
)
db = FakeDB(competences=competences)
client = FakeLLMClient(response=llm_response)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_competences(task_text="Build something")
assert result == [("Python", 1.0), ("TypeScript", 0.0)]
@@ -0,0 +1,44 @@
import os
import pytest
from teamlandkarte_mcp.azure.openai_client import AzureOpenAIClient
from teamlandkarte_mcp.config import load_config
# Allow these smoke tests to access the real Azure OpenAI endpoint when the
# required env/config is present.
pytestmark = [pytest.mark.integration, pytest.mark.allow_network]
@pytest.mark.asyncio
async def test_azure_openai_chat_completion_smoke() -> None:
"""Smoke test against a real Azure OpenAI resource (chat completion)."""
cfg = load_config("config.toml")
endpoint = (cfg.azure_openai.endpoint or "").strip()
llm_key = os.getenv("AZURE_OPENAI_LLM_API_KEY", "").strip()
if not endpoint:
pytest.skip("Azure OpenAI endpoint missing in config.toml")
if "YOUR-RESOURCE" in endpoint.upper():
pytest.skip("Azure OpenAI endpoint in config.toml is still a placeholder")
if not endpoint.startswith("https://"):
pytest.skip("Azure OpenAI endpoint must start with https://")
if not llm_key:
pytest.skip("AZURE_OPENAI_LLM_API_KEY is not set")
if not cfg.azure_openai.chat_deployment:
pytest.skip("chat_deployment not configured")
client = AzureOpenAIClient(
endpoint=endpoint,
api_version=cfg.azure_openai.api_version,
chat_deployment=cfg.azure_openai.chat_deployment,
llm_api_key=llm_key,
)
result = await client.chat_completion(
system="You are a test assistant. Respond with JSON: {\"status\": \"ok\"}",
user="ping",
)
assert isinstance(result, str)
assert "ok" in result
@@ -0,0 +1,350 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from unittest.mock import AsyncMock
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(structured.get("result"), str):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(
srv: Any,
name: str,
args: dict[str, Any] | None = None,
) -> str:
if args is None:
args = {}
return _result_to_text(await srv.call_tool(name, args))
@pytest.mark.asyncio
async def test_integration_capacity_search_filter_and_pagination(
monkeypatch,
tmp_path,
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
""".strip(),
encoding="utf-8",
)
from teamlandkarte_mcp.models import Capacity
class _FakeDB:
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return [
Capacity(
id=1,
owner_name="A",
role_name="X",
role_level=None,
begin_date=date(2025, 2, 1),
end_date=date(2025, 7, 31),
competences=["A"],
),
Capacity(
id=2,
owner_name="B",
role_name="X",
role_level=None,
begin_date=date(2025, 3, 15),
end_date=date(2025, 6, 15),
competences=["A"],
),
]
def get_recent_free_capacities(self, limit: int = 20):
return []
monkeypatch.setattr(
mcp_mod,
"create_db_client",
lambda *_args, **_kwargs: _FakeDB(),
)
# Mock LLM chat_completion to avoid network calls.
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
AsyncMock(return_value='{"similarity": 1.0}'),
)
srv = build_server(str(cfg))
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
},
)
assert "SEARCH_ID=" in res
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
filtered = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "is_fully_available": True},
)
assert "FILTER_ID=" in filtered
filter_id = filtered.split("FILTER_ID=")[1].splitlines()[0].strip()
assert "Filtered total results: 1" in filtered
page = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"filter_id": filter_id,
"category": "Low",
"page": 1,
"page_size": 20,
},
)
assert "Total items in category: 1" in page
assert "| 1 |" in page
assert "| 2 |" not in page
@pytest.mark.asyncio
async def test_integration_task_search_filters_and_pagination(
monkeypatch,
tmp_path,
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
""".strip(),
encoding="utf-8",
)
@dataclass
class _Task:
id: str
name: str | None = None
title: str = ""
description: str = ""
created_date: datetime = datetime(2025, 1, 1)
start_date: date | None = None
end_date: date | None = None
skills: list[str] = None # type: ignore[assignment]
def __post_init__(self):
if self.skills is None:
self.skills = []
class _FakeDB:
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Frontend Developer", "Backend Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["React", "TypeScript", "Python", "PostgreSQL"]
def get_open_tasks(self, limit: int = 20):
tasks = [
_Task(
id="T1",
title="Frontend Developer",
description="React and TypeScript",
created_date=datetime(2025, 1, 1),
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
skills=["React", "TypeScript"],
),
_Task(
id="T2",
title="Backend Engineer",
description="Python and PostgreSQL",
created_date=datetime(2025, 1, 1),
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
skills=["Python"],
),
]
return tasks if limit == 0 else tasks[:limit]
def get_task_by_id(self, task_id: str): # pragma: no cover
return None
def get_task_by_name(self, name: str): # pragma: no cover
return None
def get_all_capacities_with_competences(self): # pragma: no cover
return []
def get_recent_free_capacities(
self,
limit: int = 20,
): # pragma: no cover
return []
def get_capacity_by_id(self, capacity_id):
from teamlandkarte_mcp.models import Capacity
return Capacity(
id=1,
owner_name="A",
role_name="Frontend Developer",
role_level=None,
begin_date=date(2025, 4, 1),
end_date=date(2025, 8, 31),
competences=["React", "TypeScript"],
)
monkeypatch.setattr(
mcp_mod,
"create_db_client",
lambda *_args, **_kwargs: _FakeDB(),
)
# Mock LLM chat_completion to avoid network calls.
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
AsyncMock(return_value='{"similarity": 1.0}'),
)
srv = build_server(str(cfg))
res = await _call_tool(srv, "find_matching_tasks", {"capacity_id": 1})
assert "SEARCH_ID=" in res
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
out = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "task_text_filter": "typescript"},
)
assert "Filtered total results: 1" in out
assert "T1" in out
assert "T2" not in out
out2 = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "task_competence_filter": ["python"]},
)
assert "Filtered total results: 1" in out2
assert "T2" in out2
page = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"category": "Top",
"page": 1,
"page_size": 20,
},
)
assert "Total items in category:" in page
assert "task_id" in page
@@ -0,0 +1,15 @@
from __future__ import annotations
from teamlandkarte_mcp.cache.search_cache import SearchCache
def test_invalid_search_id_returns_none() -> None:
cache = SearchCache(ttl_minutes=60, max_size=10)
assert cache.get("does-not-exist") is None
def test_non_uuid_search_id_rejected_in_tool_message() -> None:
# Tool-level tests live elsewhere; here we assert the expected user-facing
# wording for non-UUID search_id inputs.
msg = "Invalid search_id format (expected UUID)."
assert "UUID" in msg
@@ -0,0 +1,120 @@
# Feature: llm-fulltext-matching, Property 5: LLM-Fehler erscheinen in der Fehlerliste, nicht als Ergebnis
"""Property-based test for LLM-error separation in ``LlmFulltextMatcher``.
For any list of capacities for which the LLM call fails on a subset
``S``, every item in ``S`` must appear in ``result.errors`` and never in
any bucket of ``result.by_category``; the remaining items must appear in
exactly one category bucket.
The fake LLM is driven by a Hypothesis-generated boolean mask: position
``i`` decides whether the call for capacity ``i+1`` raises
``AzureAPIError`` (``True``) or returns a valid JSON payload routing the
item to the ``Top`` bucket (``False``).
**Validates: Requirements 5.7, 6.7**
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
class _MaskLlm:
"""Returns a valid ``Top``-JSON for ``mask[i] is False``; else raises.
The matcher invokes ``chat_completion`` exactly once per capacity in
capacity order, so a simple call-index counter maps each call to its
mask position.
"""
def __init__(self, mask: list[bool]) -> None:
self._mask = mask
self._call_index = 0
async def chat_completion(self, system: str, user: str) -> str:
idx = self._call_index
self._call_index += 1
if self._mask[idx]:
raise AzureAPIError("simulated")
return json.dumps({"category": "Top", "rationale": "ok"})
class _StubDb:
"""Minimal ``DBClient`` double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _make_capacities(n: int) -> list[Capacity]:
return [
Capacity(
id=i + 1,
owner_name=f"o{i + 1}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
for i in range(n)
]
@settings(max_examples=100, deadline=None)
@given(mask=st.lists(st.booleans(), min_size=1, max_size=8))
def test_llm_errors_appear_only_in_errors_list(mask: list[bool]) -> None:
"""Property 5: failing items live in ``errors``, others in one bucket."""
matcher = LlmFulltextMatcher(db=_StubDb(), client=_MaskLlm(mask))
capacities = _make_capacities(len(mask))
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
result = asyncio.run(
matcher.match_capacities(
task_profile=task_profile,
capacities=capacities,
)
)
error_ids = {e.item_id for e in result.errors}
success_ids: set[str] = set()
for items in result.by_category.values():
for it in items:
success_ids.add(it.item_id)
expected_errors = {str(i + 1) for i, fail in enumerate(mask) if fail}
expected_successes = {str(i + 1) for i, fail in enumerate(mask) if not fail}
assert error_ids == expected_errors, (
f"errors mismatch: {error_ids} vs {expected_errors}"
)
assert success_ids == expected_successes, (
f"successes mismatch: {success_ids} vs {expected_successes}"
)
# Disjoint sets.
assert error_ids.isdisjoint(success_ids)
# Every capacity is accounted for exactly once across the two paths.
assert len(error_ids) + len(success_ids) == len(mask)
@@ -0,0 +1,732 @@
"""End-to-end integration tests for LLM-fulltext matching (tasks 12.2 and
12.3 of the ``llm-fulltext-matching`` spec).
These tests exercise ``find_matching_capacities`` and
``find_matching_tasks`` through ``build_server`` using fully mocked
``DBClient`` and ``AzureOpenAIClient`` collaborators. They cover:
* Task 12.2 — End-to-end runs in ``llm_fulltext`` mode for both
directions:
- The persisted ``SearchCache`` payload carries
``matching_method``, the **ungekürzte** rationale and an
``errors`` list.
- The availability prefilter behaves identically in score and
LLM-fulltext mode (out-of-window capacities are dropped).
* Task 12.3 — Markdown table headers are stable across modes:
- Score mode keeps the existing ``Role Score`` / ``Competence
Score`` / ``Overall Score`` columns.
- LLM mode ends with ``... | Category | Begründung |`` and
drops all numeric score columns.
_Requirements: 5.1, 5.2, 6.1, 6.2, 7.1, 7.4, 8.1, 8.2, 8.6, 9.5_
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.cache.search_cache import SearchCache
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity
# ---------------------------------------------------------------------------
# Test config and helpers
# ---------------------------------------------------------------------------
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
# ---------------------------------------------------------------------------
# Fake DB
# ---------------------------------------------------------------------------
@dataclass
class _Task:
"""In-test stand-in for the production ``Task`` dataclass."""
id: str
title: str
description: str
skills: list[str] = field(default_factory=list)
name: str | None = None
created_date: datetime = datetime(2025, 1, 1)
start_date: date | None = None
end_date: date | None = None
class _FakeDB:
"""In-memory ``DBClient`` double used across the integration tests.
The fake supports both directions (capacity_search and task_search)
and exposes both the single-row and the batch capacity-fulltext
methods used by the LLM matcher.
"""
def __init__(
self,
*,
capacities: list[Capacity] | None = None,
tasks: list[Any] | None = None,
capacity_extras: dict[
str,
dict[str, Any],
]
| None = None,
) -> None:
self._capacities = capacities or []
self._tasks = tasks or []
# Map capacity_id (str) -> {description, certificates, references}.
self._extras = capacity_extras or {}
# ---- generic stubs used by build_server boot-time --------------------
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return list({c.role_name for c in self._capacities if c.role_name})
def get_all_competence_names(self) -> list[str]:
names: set[str] = set()
for c in self._capacities:
names.update(c.competences)
for t in self._tasks:
names.update(getattr(t, "skills", []) or [])
return sorted(names)
# ---- capacity / task readers ----------------------------------------
def get_all_capacities_with_competences(self) -> list[Capacity]:
return list(self._capacities)
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
return []
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
target = str(capacity_id)
for c in self._capacities:
if str(c.id) == target:
return c
return None
def get_open_tasks(self, limit: int = 20) -> list[Any]:
return list(self._tasks) if limit == 0 else list(self._tasks)[:limit]
def get_task_by_id(self, task_id: str): # pragma: no cover
return None
def get_task_by_name(self, name: str): # pragma: no cover
return None
# ---- capacity fulltext extras ---------------------------------------
def get_capacity_description(
self, capacity_id: int | str
) -> str | None:
return self._extras.get(str(capacity_id), {}).get("description")
def get_capacity_certificates(
self, capacity_id: int | str
) -> list[str]:
return list(
self._extras.get(str(capacity_id), {}).get("certificates") or []
)
def get_capacity_references(
self, capacity_id: int | str
) -> list[dict]:
return list(
self._extras.get(str(capacity_id), {}).get("references") or []
)
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {
str(i): self._extras.get(str(i), {}).get("description")
for i in capacity_ids
}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {
str(i): list(
self._extras.get(str(i), {}).get("certificates") or []
)
for i in capacity_ids
}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {
str(i): list(
self._extras.get(str(i), {}).get("references") or []
)
for i in capacity_ids
}
# ---------------------------------------------------------------------------
# Server builder
# ---------------------------------------------------------------------------
def _build_server_with(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
*,
fake_db: _FakeDB,
chat_response: str | None = None,
chat_side_effect: Any = None,
) -> Any:
"""Build an MCP server backed by ``fake_db`` and a stubbed LLM.
Either ``chat_response`` (constant payload) or ``chat_side_effect``
(callable taking ``(system, user)`` and returning a string or
raising) must be provided.
"""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
)
if chat_side_effect is not None:
async def _fake_chat(self, system, user): # noqa: ARG001
outcome = chat_side_effect(system, user)
if isinstance(outcome, BaseException):
raise outcome
return outcome
else:
async def _fake_chat(self, system, user): # noqa: ARG001
return chat_response or "{}"
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client."
"AzureOpenAIClient.chat_completion",
_fake_chat,
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
return build_server(str(cfg))
def _patch_capturing_store(
monkeypatch: pytest.MonkeyPatch,
) -> list[dict[str, Any]]:
"""Patch ``SearchCache.store_search`` to capture its ``results``.
Returns a list that is appended to on every call (most recent
payload last). The original method is still invoked so ``search_id``
bookkeeping continues to work.
"""
captured: list[dict[str, Any]] = []
original = SearchCache.store_search
def _capturing(self, *, task_id, requirements, results):
captured.append(results)
return original(
self,
task_id=task_id,
requirements=requirements,
results=results,
)
monkeypatch.setattr(SearchCache, "store_search", _capturing)
return captured
# ---------------------------------------------------------------------------
# Common fixtures
# ---------------------------------------------------------------------------
def _two_capacities_one_in_window() -> list[Capacity]:
"""Capacity 1 overlaps the test window 2025-03-01..2025-06-30, 2 does not."""
return [
Capacity(
id=1,
owner_name="Alice",
role_name="X",
role_level=None,
begin_date=date(2025, 2, 1),
end_date=date(2025, 7, 31),
competences=["A"],
),
Capacity(
id=2,
owner_name="Bob",
role_name="X",
role_level=None,
begin_date=date(2026, 1, 1),
end_date=date(2026, 12, 31),
competences=["A"],
),
]
# ===========================================================================
# Task 12.2 — End-to-end integration tests
# ===========================================================================
@pytest.mark.asyncio
async def test_e2e_find_matching_capacities_llm_mode_caches_unshortened_rationale(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""LLM mode persists ``matching_method``, full rationale and errors list.
Validates: requirements 5.1, 7.4, 8.6, 9.5.
"""
long_rationale = "a" * 350
fake = _FakeDB(capacities=_two_capacities_one_in_window())
chat_payload = json.dumps(
{"category": "Top", "rationale": long_rationale},
ensure_ascii=False,
)
captured = _patch_capturing_store(monkeypatch)
srv = _build_server_with(
monkeypatch, tmp_path, fake_db=fake, chat_response=chat_payload
)
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
"matching_method": "llm_fulltext",
},
)
assert "SEARCH_ID=" in res, res
assert captured, "store_search was not called"
payload = captured[-1]
assert payload["matching_method"] == "llm_fulltext"
assert isinstance(payload.get("errors"), list)
top_items = payload["by_category"].get("Top", [])
assert len(top_items) == 1, (
f"only the in-window capacity should be categorised, got "
f"{[i.get('id') for i in top_items]}"
)
persisted_rationale = top_items[0]["rationale"]
assert len(persisted_rationale) >= 350, (
f"persisted rationale was truncated: len={len(persisted_rationale)}"
)
assert persisted_rationale == long_rationale
# Capacity 2 is outside the window and must not appear in any
# category (availability prefilter).
all_items = [
item
for cat_items in payload["by_category"].values()
for item in cat_items
]
ids = {str(it.get("id")) for it in all_items}
assert ids == {"1"}, f"expected only capacity 1, got {ids!r}"
@pytest.mark.asyncio
async def test_e2e_find_matching_capacities_availability_filter_same_in_both_modes(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""Score and LLM mode apply identical availability prefilters.
Validates: requirements 5.2, 9.5.
"""
fake = _FakeDB(capacities=_two_capacities_one_in_window())
# Single payload satisfies both consumers (similarity for score path,
# category/rationale for LLM path).
chat_payload = json.dumps(
{
"similarity": 1.0,
"category": "Top",
"rationale": "ok",
},
ensure_ascii=False,
)
captured = _patch_capturing_store(monkeypatch)
srv = _build_server_with(
monkeypatch, tmp_path, fake_db=fake, chat_response=chat_payload
)
args_common = {
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
}
# Score-mode run.
score_res = await _call_tool(
srv,
"find_matching_capacities",
{**args_common, "matching_method": "score"},
)
assert "SEARCH_ID=" in score_res
score_payload = captured[-1]
assert score_payload["matching_method"] == "score"
score_ids = {
str(item.get("id"))
for cat in score_payload["by_category"].values()
for item in cat
}
# LLM-mode run.
llm_res = await _call_tool(
srv,
"find_matching_capacities",
{**args_common, "matching_method": "llm_fulltext"},
)
assert "SEARCH_ID=" in llm_res
llm_payload = captured[-1]
assert llm_payload["matching_method"] == "llm_fulltext"
llm_ids = {
str(item.get("id"))
for cat in llm_payload["by_category"].values()
for item in cat
}
assert score_ids == {"1"}, f"score-mode ids: {score_ids!r}"
assert llm_ids == {"1"}, f"llm-mode ids: {llm_ids!r}"
@pytest.mark.asyncio
async def test_e2e_find_matching_tasks_llm_mode_persists_errors_and_rationale(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``find_matching_tasks`` persists errors list and full rationale.
The mock alternates between a successful payload (long rationale,
Good) and ``AzureAPIError`` so exactly one task is categorised and
the other ends up in ``errors``.
Validates: requirements 6.1, 6.2, 7.4, 8.6, 9.5.
"""
long_rationale = "b" * 360
capacity = Capacity(
id=1,
owner_name="Alice",
role_name="Frontend Developer",
role_level=None,
begin_date=date(2025, 4, 1),
end_date=date(2025, 8, 31),
competences=["React", "TypeScript"],
)
tasks = [
_Task(
id="T1",
title="Frontend Developer",
description="React and TypeScript",
skills=["React"],
),
_Task(
id="T2",
title="Backend Engineer",
description="Python",
skills=["Python"],
),
]
fake = _FakeDB(capacities=[capacity], tasks=tasks)
state = {"call": 0}
def _side_effect(_system: str, _user: str) -> Any:
n = state["call"]
state["call"] += 1
if n % 2 == 0:
return json.dumps(
{"category": "Good", "rationale": long_rationale},
ensure_ascii=False,
)
return AzureAPIError("LLM call failed")
captured = _patch_capturing_store(monkeypatch)
srv = _build_server_with(
monkeypatch,
tmp_path,
fake_db=fake,
chat_side_effect=_side_effect,
)
res = await _call_tool(
srv,
"find_matching_tasks",
{"capacity_id": 1, "matching_method": "llm_fulltext"},
)
assert "SEARCH_ID=" in res
assert captured, "store_search was not called"
payload = captured[-1]
assert payload["matching_method"] == "llm_fulltext"
good = payload["by_category"].get("Good", [])
assert len(good) == 1, (
f"expected exactly one Good item, got {len(good)}: {good!r}"
)
assert good[0]["rationale"] == long_rationale
assert len(good[0]["rationale"]) >= 360
errors = payload["errors"]
assert isinstance(errors, list)
assert len(errors) == 1, f"expected one error, got {errors!r}"
error_ids = {e["item_id"] for e in errors}
good_ids = {str(item.get("task_id") or item.get("id")) for item in good}
assert error_ids.isdisjoint(good_ids), (
"an item must appear in either by_category or errors, never both"
)
# ===========================================================================
# Task 12.3 — Markdown header snapshot tests
# ===========================================================================
@pytest.mark.asyncio
async def test_snapshot_capacity_search_score_mode_header(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""Score mode keeps the existing 9-column capacity table header.
Validates: requirement 7.1.
"""
fake = _FakeDB(capacities=_two_capacities_one_in_window())
srv = _build_server_with(
monkeypatch,
tmp_path,
fake_db=fake,
chat_response='{"similarity": 1.0}',
)
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
"matching_method": "score",
},
)
expected_header = (
"| ID | Owner | Role | Competences | Availability "
"| Role Score | Competence Score | Overall Score | Category |"
)
assert expected_header in res, res
assert "Begründung" not in res
@pytest.mark.asyncio
async def test_snapshot_capacity_search_llm_mode_header(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""LLM mode header ends with ``... | Category | Begründung |``.
Validates: requirements 8.1, 8.2.
"""
fake = _FakeDB(capacities=_two_capacities_one_in_window())
srv = _build_server_with(
monkeypatch,
tmp_path,
fake_db=fake,
chat_response='{"category":"Top","rationale":"ok"}',
)
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
"matching_method": "llm_fulltext",
},
)
assert "| Category | Begründung |" in res, res
assert "| Role Score |" not in res
assert "| Competence Score |" not in res
assert "| Overall Score |" not in res
@pytest.mark.asyncio
async def test_snapshot_task_search_score_mode_header(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""Score mode for ``find_matching_tasks`` keeps the score columns.
Validates: requirement 7.1.
"""
capacity = Capacity(
id=1,
owner_name="Alice",
role_name="Frontend Developer",
role_level=None,
begin_date=date(2025, 4, 1),
end_date=date(2025, 8, 31),
competences=["React"],
)
tasks = [
_Task(
id="T1",
title="Frontend Developer",
description="React",
skills=["React"],
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
)
]
fake = _FakeDB(capacities=[capacity], tasks=tasks)
srv = _build_server_with(
monkeypatch,
tmp_path,
fake_db=fake,
chat_response='{"similarity": 1.0}',
)
res = await _call_tool(
srv,
"find_matching_tasks",
{"capacity_id": 1, "matching_method": "score"},
)
assert (
"| Role Score | Competence Score | Overall Score | Category |"
in res
), res
assert "Begründung" not in res
@pytest.mark.asyncio
async def test_snapshot_task_search_llm_mode_header(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""LLM mode for ``find_matching_tasks`` ends with Begründung header.
Validates: requirements 8.1, 8.2.
"""
capacity = Capacity(
id=1,
owner_name="Alice",
role_name="Frontend Developer",
role_level=None,
begin_date=date(2025, 4, 1),
end_date=date(2025, 8, 31),
competences=["React"],
)
tasks = [
_Task(
id="T1",
title="Frontend Developer",
description="React",
skills=["React"],
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
)
]
fake = _FakeDB(capacities=[capacity], tasks=tasks)
srv = _build_server_with(
monkeypatch,
tmp_path,
fake_db=fake,
chat_response='{"category":"Good","rationale":"ok"}',
)
res = await _call_tool(
srv,
"find_matching_tasks",
{"capacity_id": 1, "matching_method": "llm_fulltext"},
)
assert "| Category | Begründung |" in res, res
assert "| Role Score |" not in res
assert "| Competence Score |" not in res
assert "| Overall Score |" not in res
@@ -0,0 +1,173 @@
# Feature: llm-fulltext-matching, Property 4: Ungültige LLM-Kategorie wird auf Irrelevant gemappt
"""Property-based test for invalid LLM categories in ``LlmFulltextMatcher``.
For any LLM response ``{"category": X, "rationale": R}`` where ``X`` is
not one of the canonical categories
``("Top", "Good", "Partial", "Low", "Irrelevant")`` (case-insensitive,
trimmed), the matcher must:
1. Route the item exclusively to the ``Irrelevant`` bucket of
``result.by_category``.
2. Preserve the original rationale ``R`` in the stored item rationale.
3. Append a deterministic hint of the form
``[Hinweis: ungültige LLM-Kategorie: ...]`` to the rationale.
4. Not record the item in ``result.errors``.
The test exercises the public ``match_capacities`` path with a single
capacity to validate the matcher behaviour end-to-end (not just the
``normalize_category`` helper). The ``AzureOpenAIClient`` and the
``DBClient`` are replaced by minimal in-memory doubles.
**Validates: Requirements 5.6, 6.6**
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_ALLOWED_LOWER = {"top", "good", "partial", "low", "irrelevant"}
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Any short string whose trimmed/lowercased form is NOT one of the
# canonical categories. ``normalize_category`` will treat such values as
# invalid and the matcher must route them to ``Irrelevant``.
_invalid_category = st.text(max_size=20).filter(
lambda s: s.strip().lower() not in _ALLOWED_LOWER
)
# Free-form rationale text the fake LLM returns alongside the invalid
# category. The matcher must keep the original (non-whitespace) content
# in the stored rationale.
_rationale = st.text(max_size=120)
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _StubLlm:
"""Minimal ``AzureOpenAIClient`` double returning a fixed JSON payload."""
def __init__(self, *, category: str, rationale: str) -> None:
self._payload = json.dumps(
{"category": category, "rationale": rationale}
)
async def chat_completion(self, system: str, user: str) -> str:
return self._payload
class _StubDb:
"""Minimal ``DBClient`` double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _make_capacity() -> Capacity:
return Capacity(
id=42,
owner_name="o",
role_name="X",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
def _make_task_profile() -> TaskProfile:
return TaskProfile(id="t", title="T", description="D", skills=[])
# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(category=_invalid_category, rationale=_rationale)
def test_invalid_llm_category_routes_to_irrelevant(
category: str, rationale: str
) -> None:
"""Property 4: invalid LLM categories are mapped to ``Irrelevant``.
For any invalid category string and any rationale the LLM returns,
the matcher places the item exclusively in the ``Irrelevant`` bucket,
preserves the (trimmed) original rationale text in the stored
rationale, appends the deterministic hint, and does not produce an
error entry.
"""
matcher = LlmFulltextMatcher(
db=_StubDb(),
client=_StubLlm(category=category, rationale=rationale),
)
capacity = _make_capacity()
task_profile = _make_task_profile()
result = asyncio.run(
matcher.match_capacities(
task_profile=task_profile,
capacities=[capacity],
)
)
# All five canonical buckets are present.
assert set(result.by_category.keys()) == {
"Top",
"Good",
"Partial",
"Low",
"Irrelevant",
}
# The item is routed exclusively to ``Irrelevant``.
assert result.by_category["Top"] == []
assert result.by_category["Good"] == []
assert result.by_category["Partial"] == []
assert result.by_category["Low"] == []
assert len(result.by_category["Irrelevant"]) == 1
item = result.by_category["Irrelevant"][0]
assert item.item_id == "42"
assert item.category == "Irrelevant"
# The rationale carries the deterministic invalid-category hint.
assert "[Hinweis: ungültige LLM-Kategorie:" in item.rationale
# The original rationale text is preserved (after trimming, since the
# matcher applies ``.strip()`` to the combined rationale).
trimmed = rationale.strip()
if trimmed:
assert trimmed in item.rationale
# No error entries are produced for invalid categories: the item is a
# regular (re-routed) result, not a failure.
assert result.errors == []
@@ -0,0 +1,363 @@
"""Unit tests for ``LlmFulltextMatcher`` with a mocked ``AzureOpenAIClient``.
These tests cover task 3.10 of the ``llm-fulltext-matching`` spec:
* success case (valid category + rationale),
* invalid JSON,
* valid JSON but not an object,
* valid JSON with an unknown category,
* ``AzureAPIError`` raised by ``chat_completion``,
* generic exception raised by ``chat_completion``,
* mixed batch (deterministic ordering, sorting, error separation),
* task-direction success (``match_tasks``).
Validates: Requirements 5.4, 5.5, 5.6, 5.7, 5.8, 6.4, 6.5, 6.6, 6.7, 6.8.
"""
from __future__ import annotations
import asyncio
import json
import re
from datetime import datetime
from typing import Any
import pytest
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import (
CapacityProfile,
CapacityReferenceEntry,
TaskProfile,
)
from teamlandkarte_mcp.models import Capacity, Task
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _StubDb:
"""Minimal ``DBClient`` double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
class _StaticLlm:
"""Returns the same payload (or raises) for every ``chat_completion`` call.
Use for single-item tests where the prompt content is irrelevant.
"""
def __init__(
self,
payload: str | None = None,
exc: BaseException | None = None,
) -> None:
self._payload = payload
self._exc = exc
async def chat_completion(self, system: str, user: str) -> str:
if self._exc is not None:
raise self._exc
assert self._payload is not None
return self._payload
class _MapLlm:
"""Returns a payload (or raises) keyed by the ``ID:`` line of the prompt.
The matcher emits a line ``ID: <id>`` in the user prompt for both
directions. The mock parses that line to look up the response, which
keeps the test deterministic regardless of iteration order.
"""
_ID_RE = re.compile(r"ID:\s*(\S+)")
def __init__(
self,
id_to_payload: dict[str, str],
id_to_exception: dict[str, BaseException] | None = None,
) -> None:
self._payloads = id_to_payload
self._exceptions = id_to_exception or {}
async def chat_completion(self, system: str, user: str) -> str:
match = self._ID_RE.search(user)
cid = match.group(1) if match else "unknown"
if cid in self._exceptions:
raise self._exceptions[cid]
return self._payloads.get(
cid, '{"category":"Irrelevant","rationale":"none"}'
)
# ---------------------------------------------------------------------------
# Factories
# ---------------------------------------------------------------------------
def _cap(cid: int) -> Capacity:
return Capacity(
id=cid,
owner_name=f"o{cid}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
def _task_profile() -> TaskProfile:
return TaskProfile(id="t1", title="T", description="D", skills=[])
def _capacity_profile() -> CapacityProfile:
return CapacityProfile(
id="42",
owner_name="alice",
role_name="Engineer",
competences=["python", "sql"],
description="senior backend engineer",
references=[
CapacityReferenceEntry(partner_name="ACME", projects="proj-x"),
CapacityReferenceEntry(partner_name="", projects="internal"),
],
certificates=["cert-A"],
)
def _make_task(tid: str) -> Task:
return Task(
id=tid,
name=f"name-{tid}",
title=f"Task {tid}",
description=f"desc {tid}",
start_date=None,
end_date=None,
created_date=datetime(2025, 1, 1),
skills=["python"],
)
_CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant")
# ---------------------------------------------------------------------------
# Tests: match_capacities
# ---------------------------------------------------------------------------
def test_success_case_valid_category_and_rationale() -> None:
"""Valid JSON with a canonical category lands in the right bucket."""
payload = json.dumps({"category": "Top", "rationale": "perfect fit"})
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=[_cap(7)],
)
)
assert result.errors == []
assert result.by_category["Top"] and len(result.by_category["Top"]) == 1
item = result.by_category["Top"][0]
assert item.item_id == "7"
assert item.category == "Top"
assert item.rationale == "perfect fit"
# Other buckets are empty but present.
for cat in ("Good", "Partial", "Low", "Irrelevant"):
assert result.by_category[cat] == []
def test_invalid_json_routes_to_errors() -> None:
"""Non-JSON LLM output is recorded as an error, not a result."""
matcher = LlmFulltextMatcher(
db=_StubDb(), client=_StaticLlm("not json at all")
)
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=[_cap(7)],
)
)
assert len(result.errors) == 1
err = result.errors[0]
assert err.item_id == "7"
assert err.error.startswith("invalid JSON: ")
# No item is categorized.
for cat in _CATEGORIES:
assert result.by_category[cat] == []
def test_valid_json_not_an_object_routes_to_errors() -> None:
"""JSON that is not a dict is treated as an invalid payload."""
payload = json.dumps("just a string")
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=[_cap(9)],
)
)
assert len(result.errors) == 1
err = result.errors[0]
assert err.item_id == "9"
assert err.error.startswith("invalid JSON: not an object")
for cat in _CATEGORIES:
assert result.by_category[cat] == []
def test_unknown_category_is_routed_to_irrelevant_with_hint() -> None:
"""Unknown categories are remapped to ``Irrelevant`` with a hint."""
payload = json.dumps({"category": "Bogus", "rationale": "rea"})
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=[_cap(3)],
)
)
assert result.errors == []
assert len(result.by_category["Irrelevant"]) == 1
item = result.by_category["Irrelevant"][0]
assert item.item_id == "3"
assert item.category == "Irrelevant"
assert "rea" in item.rationale
assert "[Hinweis: ungültige LLM-Kategorie:" in item.rationale
for cat in ("Top", "Good", "Partial", "Low"):
assert result.by_category[cat] == []
def test_azure_api_error_is_recorded_in_errors() -> None:
"""``AzureAPIError`` from ``chat_completion`` lands in the error list."""
matcher = LlmFulltextMatcher(
db=_StubDb(), client=_StaticLlm(exc=AzureAPIError("boom"))
)
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=[_cap(11)],
)
)
assert len(result.errors) == 1
err = result.errors[0]
assert err.item_id == "11"
assert err.error == "AzureAPIError: boom"
for cat in _CATEGORIES:
assert result.by_category[cat] == []
def test_generic_exception_is_recorded_in_errors() -> None:
"""A non-Azure exception is also routed to the error list."""
matcher = LlmFulltextMatcher(
db=_StubDb(), client=_StaticLlm(exc=RuntimeError("nope"))
)
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=[_cap(13)],
)
)
assert len(result.errors) == 1
err = result.errors[0]
assert err.item_id == "13"
assert err.error == "RuntimeError: nope"
for cat in _CATEGORIES:
assert result.by_category[cat] == []
def test_mixed_batch_orders_buckets_lexicographically() -> None:
"""Mixed batch: success, success, JSON error, success → sorted output."""
id_to_payload = {
"30": json.dumps({"category": "Top", "rationale": "t30"}),
"10": json.dumps({"category": "Top", "rationale": "t10"}),
"20": "not json",
"40": json.dumps({"category": "Good", "rationale": "g40"}),
}
matcher = LlmFulltextMatcher(
db=_StubDb(), client=_MapLlm(id_to_payload)
)
capacities = [_cap(30), _cap(10), _cap(20), _cap(40)]
result = asyncio.run(
matcher.match_capacities(
task_profile=_task_profile(),
capacities=capacities,
)
)
# Lexicographically ascending within each category.
top_ids = [it.item_id for it in result.by_category["Top"]]
assert top_ids == ["10", "30"]
good_ids = [it.item_id for it in result.by_category["Good"]]
assert good_ids == ["40"]
for cat in ("Partial", "Low", "Irrelevant"):
assert result.by_category[cat] == []
# All five canonical buckets are present in the dict.
assert set(result.by_category.keys()) == set(_CATEGORIES)
# The invalid-JSON item is only in the error list.
assert len(result.errors) == 1
assert result.errors[0].item_id == "20"
assert result.errors[0].error.startswith("invalid JSON: ")
# ---------------------------------------------------------------------------
# Tests: match_tasks
# ---------------------------------------------------------------------------
def test_match_tasks_success_case() -> None:
"""``match_tasks`` mirrors the capacity direction for the success path."""
payload = json.dumps({"category": "Good", "rationale": "task fit"})
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
task = _make_task("00T123")
result = asyncio.run(
matcher.match_tasks(
capacity_profile=_capacity_profile(),
tasks=[task],
)
)
assert result.errors == []
assert len(result.by_category["Good"]) == 1
item = result.by_category["Good"][0]
assert item.item_id == "00T123"
assert item.category == "Good"
assert item.rationale == "task fit"
for cat in ("Top", "Partial", "Low", "Irrelevant"):
assert result.by_category[cat] == []
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-x", "--tb=short", "-q"]))
@@ -0,0 +1,168 @@
# Feature: llm-fulltext-matching, Property 6:
# Ergebnisse sind innerhalb jeder Kategorie deterministisch sortiert
"""Property-based test for deterministic sorting in ``LlmFulltextMatcher``.
For any ``LlmFulltextResult`` produced by ``match_capacities``, the
``item_id``-sequence within each category bucket must be strictly
ascending (lexicographic) and must remain identical when the input
``capacities`` list is permuted.
The fake LLM is driven by an injected ``capacity_id → category`` mapping.
Because the matcher invokes ``chat_completion`` once per capacity in
input order, we cannot rely on call index alone (the index would point
to a different capacity in the permuted run). Instead, the fake LLM
parses the ``ID:`` line emitted by the matcher's user prompt to recover
the capacity id and looks up the assigned category.
**Validates: Requirements 5.8, 6.8**
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_ID_RE = re.compile(r"ID:\s*(\S+)")
_CATEGORIES: tuple[str, ...] = (
"Top",
"Good",
"Partial",
"Low",
"Irrelevant",
)
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _MapLlm:
"""Returns a JSON payload whose category depends on the prompt's ID line.
The matcher emits a line ``ID: <capacity_id>`` in the user prompt. By
parsing that line we can return the same category for the same
capacity regardless of the order in which the matcher iterates.
"""
def __init__(self, id_to_category: dict[str, str]) -> None:
self._map = id_to_category
async def chat_completion(self, system: str, user: str) -> str:
match = _ID_RE.search(user)
cap_id = match.group(1) if match else "unknown"
category = self._map.get(cap_id, "Irrelevant")
return json.dumps(
{"category": category, "rationale": f"reason for {cap_id}"}
)
class _StubDb:
"""Minimal ``DBClient`` double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
_ids = st.lists(
st.integers(min_value=1, max_value=99),
min_size=2,
max_size=8,
unique=True,
)
def _make_capacity(cap_id: int) -> Capacity:
return Capacity(
id=cap_id,
owner_name=f"o{cap_id}",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(ids=_ids, data=st.data())
def test_sort_within_category_is_deterministic_under_permutations(
ids: list[int], data: st.DataObject
) -> None:
"""Property 6: order within each category is stable under permutations.
Two runs of ``match_capacities`` with the same ``capacity_id → category``
mapping but with the input list in different orders must yield the
same ``item_id`` sequence per category, and that sequence must be
lexicographically ascending.
"""
id_to_category = {
str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids
}
capacities = [_make_capacity(i) for i in ids]
permutation_order = data.draw(st.permutations(capacities))
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
matcher_a = LlmFulltextMatcher(
db=_StubDb(), client=_MapLlm(id_to_category)
)
result_a = asyncio.run(
matcher_a.match_capacities(
task_profile=task_profile,
capacities=capacities,
)
)
matcher_b = LlmFulltextMatcher(
db=_StubDb(), client=_MapLlm(id_to_category)
)
result_b = asyncio.run(
matcher_b.match_capacities(
task_profile=task_profile,
capacities=permutation_order,
)
)
for cat in _CATEGORIES:
ids_a = [it.item_id for it in result_a.by_category[cat]]
ids_b = [it.item_id for it in result_b.by_category[cat]]
assert ids_a == ids_b, (
f"category {cat} order differs: {ids_a} vs {ids_b}"
)
assert ids_a == sorted(ids_a), (
f"category {cat} not sorted ascending: {ids_a}"
)
@@ -0,0 +1,311 @@
from __future__ import annotations
from datetime import date
from unittest.mock import AsyncMock, MagicMock
import pytest
from teamlandkarte_mcp.config import MatchingConfig, MatchingThresholds
from teamlandkarte_mcp.matching.matcher import Matcher
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
from teamlandkarte_mcp.models import Capacity, Requirements
class _FakeSimilarityEngine:
def __init__(
self,
*,
comp_score: float = 1.0,
role_score: float = 0.0,
):
self._comp_score = comp_score
self._role_score = role_score
@property
def use_bm25_search(self) -> bool:
"""Always False — the fake engine never builds a global index."""
return False
async def compute_competence_similarity( # noqa: ANN001
self,
required,
_candidate,
global_index=None, # noqa: ANN001
):
# Return per required competence entries.
return {
r: {"score": self._comp_score, "best_match": None, "rationale": ""}
for r in required
}
async def compute_role_similarity( # noqa: ANN001
self,
_required_role,
_candidate_role,
):
return self._role_score
@pytest.mark.asyncio
async def test_matcher_uses_similarity_engine_scores() -> None:
cfg = MatchingConfig(
competence_weight=0.8,
role_weight=0.2,
thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4),
require_confirmation=True,
)
matcher = Matcher(_FakeSimilarityEngine(), cfg) # type: ignore[arg-type]
caps = [
Capacity(
id=1,
owner_name="A",
role_name="Dev",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=["Python"],
)
]
req = Requirements(
role_name="Dev",
competences=["Python"],
date_start=None,
date_end=None,
description=None,
)
result = await matcher.match(caps, req)
assert len(result.scored) == 1
assert result.scored[0].competence_score == 1.0
assert result.scored[0].overall_score == 0.8
assert result.scored[0].category == "Top"
# ---------------------------------------------------------------------------
# BM25 integration: false-positive elimination
# ---------------------------------------------------------------------------
def _cfg() -> MatchingConfig:
return MatchingConfig(
competence_weight=0.8,
role_weight=0.2,
thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4),
require_confirmation=False,
)
def _async_client() -> MagicMock:
"""Mock AzureOpenAIClient."""
client = MagicMock()
client.chat_completion = AsyncMock(return_value='{"similarity": 0.0}')
return client
def _bm25_engine() -> SimilarityEngine:
"""Minimal SimilarityEngine with BM25 competence similarity."""
return SimilarityEngine(
client=_async_client(), # type: ignore[arg-type]
)
@pytest.mark.asyncio
async def test_bm25_candidate_with_exact_skills_scores_higher() -> None:
"""BM25: candidate with exact skills scores higher than JS-only candidate."""
engine = _bm25_engine()
matcher = Matcher(engine, _cfg())
caps = [
Capacity(
id=1,
owner_name="Alice",
role_name="ML Engineer",
role_level=None,
begin_date=None,
end_date=None,
competences=["Python", "Machine Learning", "Pandas"],
),
Capacity(
id=2,
owner_name="Bob",
role_name="Frontend Dev",
role_level=None,
begin_date=None,
end_date=None,
competences=["JavaScript", "TypeScript", "Node.js", "Vue.js"],
),
]
req = Requirements(
role_name="ML Engineer",
competences=["Python", "Machine Learning"],
date_start=None,
date_end=None,
description=None,
)
result = await matcher.match(caps, req)
scored = {s.capacity.id: s for s in result.scored}
# Alice has exact matches → competence_score > 0
assert scored[1].competence_score > 0.0
# Bob has zero token overlap → competence_score == 0.0 (false-positive eliminated)
assert scored[2].competence_score == 0.0
# Alice scores higher overall
assert scored[1].overall_score > scored[2].overall_score
@pytest.mark.asyncio
async def test_bm25_false_positive_eliminated() -> None:
"""BM25: JS/TS candidate gets 0.0 when Python/ML is required."""
engine = _bm25_engine()
matcher = Matcher(engine, _cfg())
caps = [
Capacity(
id=3,
owner_name="Carol",
role_name="Frontend Dev",
role_level=None,
begin_date=None,
end_date=None,
competences=["JavaScript", "TypeScript", "Node.js", "Vue.js",
"PWA", "CI/CD"],
),
]
req = Requirements(
role_name="Data Scientist",
competences=["Python", "Machine Learning"],
date_start=None,
date_end=None,
description=None,
)
result = await matcher.match(caps, req)
assert result.scored[0].competence_score == 0.0
@pytest.mark.asyncio
async def test_bm25_auto_tag_candidate_matches_after_expansion() -> None:
"""BM25 + auto-tag: 'ML' candidate matches 'Machine Learning' via mock tagger.
A second candidate "Eve" holds "Machine Learning" as an original skill so
it enters the global BM25 corpus. After the mock tagger expands Dave's
competences to include "Machine Learning", the global index can score it
with positive IDF (N=5 unique skills in the pool).
"""
mock_tagger = MagicMock()
mock_tagger.expand_competences = AsyncMock(
return_value=["ML", "Python", "Machine Learning"]
)
engine = SimilarityEngine(
client=_async_client(), # type: ignore[arg-type]
use_auto_tagging=True,
auto_tagger=mock_tagger,
)
matcher = Matcher(engine, _cfg())
caps = [
Capacity(
id=4,
owner_name="Dave",
role_name="Data Scientist",
role_level=None,
begin_date=None,
end_date=None,
competences=["ML", "Python"],
),
# Eve's original skills make "Machine Learning" part of the global
# BM25 corpus, giving it a stable positive IDF weight.
Capacity(
id=5,
owner_name="Eve",
role_name="ML Engineer",
role_level=None,
begin_date=None,
end_date=None,
competences=["Machine Learning", "Data Science", "Statistics"],
),
]
req = Requirements(
role_name="Data Scientist",
competences=["Machine Learning", "Python"],
date_start=None,
date_end=None,
description=None,
)
result = await matcher.match(caps, req)
dave = next(s for s in result.scored if s.capacity.id == 4)
# After auto-tag expansion "Machine Learning" is in Dave's working list
# and in the global corpus → competence_score > 0
assert dave.competence_score > 0.0
# ---------------------------------------------------------------------------
# Global BM25 index construction
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_matcher_builds_global_bm25_index_across_all_candidates() -> None:
"""Matcher builds one global BM25 index from all filtered candidates' skills.
With a global corpus of seven distinct skills the IDF weights are stable
and each candidate is scored against the *same* index. Two candidates
with non-overlapping skill sets must receive different competence scores:
the one whose skills share tokens with the required competence scores
above zero, the other must score exactly zero.
This verifies the fix for the per-person IDF pathology: if a separate
index were built per-person the candidate with only one skill would get
IDF ≤ 0 and score 0.0 incorrectly.
"""
engine = _bm25_engine()
matcher = Matcher(engine, _cfg())
caps = [
# Frank: single relevant skill — previously broken by per-person N=1
Capacity(
id=10,
owner_name="Frank",
role_name="Backend Dev",
role_level=None,
begin_date=None,
end_date=None,
competences=["Python"],
),
# Grace: unrelated skills only
Capacity(
id=11,
owner_name="Grace",
role_name="Designer",
role_level=None,
begin_date=None,
end_date=None,
competences=["Figma", "Sketch", "CSS", "HTML", "Illustrator", "InDesign"],
),
]
req = Requirements(
role_name="Backend Dev",
competences=["Python"],
date_start=None,
date_end=None,
description=None,
)
result = await matcher.match(caps, req)
scored = {s.capacity.id: s for s in result.scored}
# With a global corpus of 7 unique skills, IDF("python") > 0.
# Frank's single-skill corpus would have given IDF < 0 (per-person bug).
assert scored[10].competence_score > 0.0, (
"Frank should score > 0: global IDF fixes the per-person N=1 pathology"
)
# Grace has no token overlap with "Python" → score exactly 0.0
assert scored[11].competence_score == 0.0
# Frank ranks above Grace
assert scored[10].overall_score > scored[11].overall_score
@@ -0,0 +1,230 @@
# Feature: llm-fulltext-matching, Property 9: matching_method-Validierung lehnt unbekannte Werte ab
"""Property test for ``matching_method`` validation.
The MCP tools ``find_matching_capacities`` and ``find_matching_tasks``
must reject any ``matching_method`` value whose ``strip().lower()``
representation is neither ``"score"`` nor ``"llm_fulltext"``. The
rejection must:
1. Produce an error message that mentions both allowed values
(``score`` and ``llm_fulltext``).
2. Avoid touching the database (no capacity loading) and the LLM (no
``chat_completion`` call). The validation must short-circuit BEFORE
any side-effect.
The fake database and the fake ``chat_completion`` track call counts;
both counters must remain zero for every Hypothesis-generated invalid
input.
**Validates: Requirements 1.5**
"""
from __future__ import annotations
from typing import Any
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
class _CountingDB:
"""Fake DB that counts how often ``get_all_capacities_with_competences``
is invoked. Any non-zero counter after a rejected validation is a bug.
"""
def __init__(self) -> None:
self.capacity_calls = 0
self.capacity_by_id_calls = 0
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
self.capacity_calls += 1
return []
def get_recent_free_capacities(self, limit: int = 20):
return []
def get_capacity_by_id(self, capacity_id): # pragma: no cover
self.capacity_by_id_calls += 1
return None
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _build_test_server(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> tuple[Any, _CountingDB, dict[str, int]]:
"""Construct a server backed by counting DB and LLM doubles."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
fake_db = _CountingDB()
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
)
llm_calls = {"count": 0}
async def _fake_chat_completion(self, system, user): # noqa: ARG001
llm_calls["count"] += 1
return '{"category":"Top","rationale":"r"}'
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
_fake_chat_completion,
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
srv = build_server(str(cfg))
return srv, fake_db, llm_calls
# Reject anything that, after trimming and lower-casing, is one of the
# canonical values (those are valid inputs and would not exercise the
# error path) or the empty string. The empty/whitespace-only string is
# treated by the server as "no value provided" (it then falls back to
# the configured default), so it is not part of the invalid-input space
# this property test targets. Hypothesis is otherwise free to generate
# any text.
def _is_invalid_method(value: str) -> bool:
norm = value.strip().lower()
if norm == "":
return False
return norm not in ("score", "llm_fulltext")
_INVALID_METHOD = st.text(max_size=20).filter(_is_invalid_method)
@settings(
max_examples=50,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@given(value=_INVALID_METHOD)
@pytest.mark.asyncio
async def test_invalid_matching_method_is_rejected_without_side_effects(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
value: str,
) -> None:
"""Property 9: invalid matching_method values are rejected verbatim.
The error message must list both allowed values; the database and the
LLM mock must not be touched.
"""
srv, fake_db, llm_calls = _build_test_server(monkeypatch, tmp_path)
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"matching_method": value,
},
)
# The error message references both allowed values.
assert "score" in res, (
f"missing 'score' in error response for {value!r}: {res!r}"
)
assert "llm_fulltext" in res, (
f"missing 'llm_fulltext' in error response for {value!r}: {res!r}"
)
# The response must signal an error (not a successful search).
lowered = res.lower()
assert ("error" in lowered) or ("invalid" in lowered), (
f"response is not an error for {value!r}: {res!r}"
)
# No DB/LLM access for the rejected validation path.
assert fake_db.capacity_calls == 0, (
f"DB capacity loader was invoked for invalid value {value!r}"
)
assert llm_calls["count"] == 0, (
f"LLM was invoked for invalid value {value!r}"
)
@@ -0,0 +1,114 @@
import datetime as dt
import pytest
from teamlandkarte_mcp.matching.matcher import Matcher
from teamlandkarte_mcp.matching.scorer import categorize
from teamlandkarte_mcp.mcp_server import _calculate_overlap_percentage, _task_role_text
from teamlandkarte_mcp.models import Capacity, Requirements
def test_task_role_text_title_first_fallback_to_description() -> None:
assert _task_role_text("DevOps Engineer", "foo") == "DevOps Engineer"
assert _task_role_text(" ", "Backend Developer") == "Backend Developer"
assert _task_role_text(None, " Backend Developer ") == "Backend Developer"
def test_overlap_percentage_basic() -> None:
ref_start = dt.date(2025, 3, 1)
ref_end = dt.date(2025, 6, 30)
other_start = dt.date(2025, 3, 15)
other_end = dt.date(2025, 6, 30)
# 2025-03-01..2025-06-30 inclusive is 122 days; overlap is 108 days.
assert (
_calculate_overlap_percentage(
ref_start=ref_start,
ref_end=ref_end,
other_start=other_start,
other_end=other_end,
)
== "89%"
)
def test_overlap_percentage_open_ended_other_end() -> None:
ref_start = dt.date(2025, 3, 1)
ref_end = dt.date(2025, 3, 31)
other_start = dt.date(2025, 3, 15)
other_end = None
assert (
_calculate_overlap_percentage(
ref_start=ref_start,
ref_end=ref_end,
other_start=other_start,
other_end=other_end,
)
== "55%"
)
def test_categorize_includes_irrelevant() -> None:
class _T:
top = 0.8
good = 0.6
partial = 0.4
low = 0.2
class _Cfg:
thresholds = _T()
assert categorize(0.19, _Cfg()) == "Irrelevant"
assert categorize(0.20, _Cfg()) == "Low"
@pytest.mark.asyncio
async def test_matcher_includes_irrelevant_bucket(monkeypatch) -> None:
# Arrange: Build a matcher with a fake similarity engine that returns 0 scores.
class _FakeSim:
use_bm25_search = False # tells Matcher not to build a global index
async def compute_competence_similarity(self, required, present, global_index=None):
return {r: {"score": 0.0} for r in required}
async def compute_role_similarity(self, required_role, cap_role):
return 0.0
class _Thresh:
top = 0.8
good = 0.6
partial = 0.4
low = 0.2
class _Cfg:
competence_weight = 0.8
role_weight = 0.2
thresholds = _Thresh()
matcher = Matcher(_FakeSim(), _Cfg())
cap = Capacity(
id=1,
owner_name="A",
role_name="X",
role_level=None,
begin_date=dt.date(2025, 1, 1),
end_date=dt.date(2025, 12, 31),
competences=["a"],
)
req = Requirements(
role_name="Y",
competences=["b"],
date_start=dt.date(2025, 1, 1),
date_end=dt.date(2025, 1, 2),
)
# Act
res = await matcher.match([cap], req)
# Assert
assert "Irrelevant" in res.by_category
assert len(res.by_category["Irrelevant"]) == 1
@@ -0,0 +1,429 @@
"""Unit tests for ``filter_search_results`` and ``get_results_by_category``
covering both score and LLM-fulltext modes (task 11.5 of the
``llm-fulltext-matching`` spec).
The tests verify that:
* ``get_results_by_category`` keeps the score-mode columns unchanged for
legacy ``score`` searches (regression).
* ``get_results_by_category`` renders the ``Begründung`` column and no
numeric score columns for ``llm_fulltext`` searches; META reports the
matching method.
* ``filter_search_results`` keeps the score-mode columns unchanged for
``score`` searches and tags META with ``"score"``.
* ``filter_search_results`` renders the ``Begründung`` column and no
numeric score columns for ``llm_fulltext`` searches and tags META
with ``"llm_fulltext"``.
* ``filter_search_results`` ignores ``min_similarity`` in
``llm_fulltext`` mode and surfaces the ignore note in the
``Applied Filters`` table while *not* dropping items that would
otherwise survive the remaining filters.
_Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
"""
from __future__ import annotations
import json
from datetime import date
from typing import Any
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _FakeDB:
"""Minimal DB stub returning two capacities that overlap the window."""
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_all_capacities_with_competences(self) -> list[Capacity]:
return [
Capacity(
id=1,
owner_name="Alice",
role_name="X",
role_level=None,
begin_date=date(2025, 2, 1),
end_date=date(2025, 7, 31),
competences=["A"],
),
Capacity(
id=2,
owner_name="Bob",
role_name="X",
role_level=None,
begin_date=date(2025, 3, 15),
end_date=date(2025, 6, 15),
competences=["A"],
),
]
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
return []
def get_open_tasks(self, limit: int = 20) -> list[Any]:
return []
# --- LLM-fulltext capacity extras ---------------------------------------
def get_capacity_description(self, capacity_id: int | str) -> str | None:
return None
def get_capacity_certificates(
self, capacity_id: int | str
) -> list[str]:
return []
def get_capacity_references(
self, capacity_id: int | str
) -> list[dict]:
return []
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META=") :])
raise AssertionError(f"no META= line in tool output:\n{output}")
def _extract_search_id(output: str) -> str:
assert "SEARCH_ID=" in output, f"no SEARCH_ID= in output:\n{output}"
return output.split("SEARCH_ID=")[1].splitlines()[0].strip()
def _build_server_with(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
*,
chat_response: str,
) -> Any:
"""Build an MCP server backed by ``_FakeDB`` and a stubbed LLM."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
)
async def _fake_chat(self, system, user): # noqa: ARG001
return chat_response
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
_fake_chat,
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
return build_server(str(cfg))
async def _create_score_search(srv: Any) -> str:
"""Run a score-mode search and return its ``search_id``."""
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
},
)
return _extract_search_id(res)
async def _create_llm_search(srv: Any) -> str:
"""Run an LLM-mode search and return its ``search_id``."""
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
"matching_method": "llm_fulltext",
},
)
return _extract_search_id(res)
# ---------------------------------------------------------------------------
# Tests: get_results_by_category
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_results_by_category_score_mode_preserves_columns(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""Score mode keeps the original 9-column capacity table."""
srv = _build_server_with(
monkeypatch, tmp_path, chat_response='{"similarity": 1.0}'
)
search_id = await _create_score_search(srv)
page = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"category": "Top",
"page": 1,
"page_size": 20,
},
)
# Score columns are present.
assert "| Role Score |" in page
assert "| Competence Score |" in page
assert "| Overall Score |" in page
assert "| Category |" in page
# Begründung must be absent in score mode.
assert "Begründung" not in page
# META still reports the matching method.
meta = _extract_meta(page)
assert meta.get("matching_method") == "score"
@pytest.mark.asyncio
async def test_get_results_by_category_llm_mode_renders_begruendung(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""LLM mode renders ``Begründung`` and drops numeric score columns."""
srv = _build_server_with(
monkeypatch,
tmp_path,
chat_response='{"category":"Good","rationale":"good fit"}',
)
search_id = await _create_llm_search(srv)
# Capacities are categorised as ``Good`` by the stubbed LLM response.
page = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"category": "Good",
"page": 1,
"page_size": 20,
},
)
assert "Begründung" in page, page
assert "good fit" in page
# Score columns must be absent in LLM mode.
assert "| Role Score |" not in page
assert "| Competence Score |" not in page
assert "| Overall Score |" not in page
# META reports llm_fulltext.
meta = _extract_meta(page)
assert meta.get("matching_method") == "llm_fulltext"
# ---------------------------------------------------------------------------
# Tests: filter_search_results
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_filter_search_results_score_mode_unchanged(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""Score-mode filter preview keeps numeric score columns."""
srv = _build_server_with(
monkeypatch, tmp_path, chat_response='{"similarity": 1.0}'
)
search_id = await _create_score_search(srv)
filtered = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "role_filter": "X"},
)
assert "| Role Score |" in filtered
assert "| Competence Score |" in filtered
assert "| Overall Score |" in filtered
assert "Begründung" not in filtered
meta = _extract_meta(filtered)
assert meta.get("matching_method") == "score"
@pytest.mark.asyncio
async def test_filter_search_results_llm_mode_uses_begruendung_table(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""LLM-mode filter preview uses the ``Begründung`` layout."""
srv = _build_server_with(
monkeypatch,
tmp_path,
chat_response='{"category":"Good","rationale":"good fit"}',
)
search_id = await _create_llm_search(srv)
filtered = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "role_filter": "X"},
)
assert "Begründung" in filtered
assert "| Role Score |" not in filtered
assert "| Competence Score |" not in filtered
assert "| Overall Score |" not in filtered
meta = _extract_meta(filtered)
assert meta.get("matching_method") == "llm_fulltext"
@pytest.mark.asyncio
async def test_filter_search_results_llm_mode_ignores_min_similarity_with_hint(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""``min_similarity`` is recorded as ignored in LLM mode and inactive."""
srv = _build_server_with(
monkeypatch,
tmp_path,
chat_response='{"category":"Good","rationale":"good fit"}',
)
search_id = await _create_llm_search(srv)
# Use a threshold that would normally drop everything in score mode.
filtered = await _call_tool(
srv,
"filter_search_results",
{
"search_id": search_id,
"role_filter": "X",
"min_similarity": 0.99,
},
)
# Hint surfaces in the ``Applied Filters`` table.
assert "ignored: not applicable in llm_fulltext mode" in filtered
# The threshold did not eliminate the items: both fake capacities
# remain in the filtered result set.
assert "Filtered total results: 2" in filtered, filtered
@@ -0,0 +1,623 @@
"""Unit tests for matching_method routing in both MCP matching tools.
Covers task 6.6 of the ``llm-fulltext-matching`` spec:
* ``matching_method=None`` → default (score) mode is used; the rendered
table keeps the score columns and the META JSON tags ``"score"``.
* ``matching_method="llm_fulltext"`` → the LLM matcher is invoked; the
rendered table shows the ``Begründung`` column instead of score
columns and META tags ``"llm_fulltext"``.
* ``matching_method="bogus"`` → an error message is returned that lists
both allowed values; neither the database (capacity loader / capacity
by id) nor the LLM is touched.
The tests cover both ``find_matching_capacities`` and
``find_matching_tasks``.
_Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
"""
from __future__ import annotations
import json
from datetime import date, datetime
from typing import Any
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import (
Capacity,
Task,
Team,
TeamCompetence,
TeamReference,
)
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _CountingDB:
"""Fake DB tracking calls into the capacity/task data paths."""
def __init__(self) -> None:
self.capacity_calls = 0
self.capacity_by_id_calls = 0
self.task_calls = 0
self.description_calls = 0
self.certificates_calls = 0
self.references_calls = 0
self.team_calls = 0
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X", "Frontend Developer"]
def get_all_competence_names(self) -> list[str]:
return ["A", "React"]
# --- capacity-search direction ------------------------------------------
def get_all_capacities_with_competences(self) -> list[Capacity]:
self.capacity_calls += 1
return [
Capacity(
id=1,
owner_name="A",
role_name="X",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=["A"],
)
]
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
return []
# --- task-search direction ----------------------------------------------
def get_open_tasks(self, limit: int = 20) -> list[Task]:
self.task_calls += 1
tasks = [
Task(
id="T1",
name="t1",
title="Frontend Developer",
description="React",
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
created_date=datetime(2025, 1, 1),
skills=["React"],
)
]
return tasks if limit == 0 else tasks[:limit]
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
self.capacity_by_id_calls += 1
return Capacity(
id=int(str(capacity_id)),
owner_name="A",
role_name="Frontend Developer",
role_level=None,
begin_date=date(2025, 4, 1),
end_date=date(2025, 8, 31),
competences=["React"],
)
# --- LLM-fulltext capacity extras ---------------------------------------
def get_capacity_description(self, capacity_id: int | str) -> str | None:
self.description_calls += 1
return None
def get_capacity_certificates(
self, capacity_id: int | str
) -> list[str]:
self.certificates_calls += 1
return []
def get_capacity_references(
self, capacity_id: int | str
) -> list[dict]:
self.references_calls += 1
return []
# Batch variants (used by ``match_capacities``).
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
# --- team-search direction ----------------------------------------------
def get_all_teams(self) -> list[Team]:
self.team_calls += 1
return [
Team(
team_id="t1",
ouid="ou-1",
team_name="Team Alpha",
focus_name="X",
about_us="We build things.",
offerings="APIs.",
interests="Distributed systems.",
competences=[
TeamCompetence(name="A", top_competency=True),
],
references=[
TeamReference(
partner_name="Partner One", projects="Project P"
),
],
)
]
def get_team_by_id(self, team_id: str) -> Team | None: # pragma: no cover
for t in self.get_all_teams():
if t.team_id == str(team_id):
return t
return None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META=") :])
raise AssertionError(f"no META= line in tool output:\n{output}")
def _build_server_with(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
*,
chat_completion: Any,
) -> tuple[Any, _CountingDB, dict[str, int]]:
"""Construct an MCP server backed by a counting DB and a counted LLM."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
fake_db = _CountingDB()
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
)
llm_calls = {"count": 0}
async def _wrapped(self, system, user): # noqa: ARG001
llm_calls["count"] += 1
return await chat_completion(system, user)
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
_wrapped,
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
srv = build_server(str(cfg))
return srv, fake_db, llm_calls
async def _score_payload(system: str, user: str) -> str:
return '{"similarity": 1.0}'
async def _llm_payload(system: str, user: str) -> str:
return '{"category":"Top","rationale":"good fit"}'
async def _fail_if_called(system: str, user: str) -> str: # pragma: no cover
raise AssertionError(
"chat_completion must not be called for invalid matching_method"
)
# ---------------------------------------------------------------------------
# Tests: find_matching_capacities
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_matching_capacities_default_uses_score_mode(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""No ``matching_method`` → default ``score`` schema."""
srv, _db, _llm = _build_server_with(
monkeypatch, tmp_path, chat_completion=_score_payload
)
res = await _call_tool(
srv,
"find_matching_capacities",
{"role_name": "X", "competences": ["A"]},
)
# Score-mode columns are present.
assert "| Role Score |" in res
assert "| Competence Score |" in res
assert "| Overall Score |" in res
# LLM-mode column is absent.
assert "Begründung" not in res
# META tags the search as score mode.
meta = _extract_meta(res)
assert meta.get("matching_method") == "score"
@pytest.mark.asyncio
async def test_find_matching_capacities_llm_mode_invokes_llm_matcher(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""``matching_method='llm_fulltext'`` → LLM table, no score columns."""
srv, _db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_llm_payload
)
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"matching_method": "llm_fulltext",
},
)
# The LLM was invoked at least once for the single matching capacity.
assert llm_calls["count"] >= 1, "LLM matcher path was not exercised"
# META reflects the LLM mode.
meta = _extract_meta(res)
assert meta.get("matching_method") == "llm_fulltext"
# The LLM result table includes the Begründung column.
assert "Begründung" in res, "LLM table missing the Begründung column"
# The score columns are gone in LLM mode.
assert "| Role Score |" not in res
assert "| Competence Score |" not in res
assert "| Overall Score |" not in res
@pytest.mark.asyncio
async def test_find_matching_capacities_bogus_method_returns_error(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""Invalid ``matching_method`` → error mentioning both allowed values.
Neither the capacity loader nor the LLM may be invoked.
"""
srv, fake_db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_fail_if_called
)
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"matching_method": "bogus",
},
)
lowered = res.lower()
assert "error" in lowered or "invalid" in lowered, res
assert "score" in res
assert "llm_fulltext" in res
# No side effects.
assert fake_db.capacity_calls == 0, (
"DB capacity loader was hit despite invalid matching_method"
)
assert llm_calls["count"] == 0, (
"LLM was called despite invalid matching_method"
)
# ---------------------------------------------------------------------------
# Tests: find_matching_tasks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_matching_tasks_llm_mode_invokes_llm_matcher(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""``matching_method='llm_fulltext'`` for task search uses LLM matcher."""
srv, fake_db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_llm_payload
)
res = await _call_tool(
srv,
"find_matching_tasks",
{"capacity_id": 1, "matching_method": "llm_fulltext"},
)
# The LLM was invoked for the candidate task.
assert llm_calls["count"] >= 1, "LLM matcher path was not exercised"
# META reflects the LLM mode.
meta = _extract_meta(res)
assert meta.get("matching_method") == "llm_fulltext"
# LLM-mode column is present, score columns absent.
assert "Begründung" in res, "LLM task table missing Begründung column"
assert "| Role Score |" not in res
assert "| Competence Score |" not in res
assert "| Overall Score |" not in res
# The LLM-fulltext direction loads description/certificates/references
# for the resolved capacity.
assert fake_db.description_calls == 1
assert fake_db.certificates_calls == 1
assert fake_db.references_calls == 1
@pytest.mark.asyncio
async def test_find_matching_tasks_bogus_method_returns_error(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""Invalid method on task search → error, no DB/LLM access."""
srv, fake_db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_fail_if_called
)
res = await _call_tool(
srv,
"find_matching_tasks",
{"capacity_id": 1, "matching_method": "bogus"},
)
lowered = res.lower()
assert "error" in lowered or "invalid" in lowered, res
assert "score" in res
assert "llm_fulltext" in res
# No DB lookups for the rejected validation path.
assert fake_db.capacity_by_id_calls == 0, (
"get_capacity_by_id was called despite invalid matching_method"
)
assert fake_db.task_calls == 0, (
"get_open_tasks was called despite invalid matching_method"
)
assert llm_calls["count"] == 0, (
"LLM was called despite invalid matching_method"
)
# ---------------------------------------------------------------------------
# Tests: find_matching_teams (Profile_Type="team")
# ---------------------------------------------------------------------------
#
# These cases mirror the capacity-routing tests above but exercise the
# team-profile path so that the routing surface is covered for both
# ``Profile_Type`` values (``capacity``, ``team``) and both
# ``matching_method`` values (gemocktes LLM und gemockte DB).
#
# _Requirements: 12.7_
@pytest.mark.asyncio
async def test_find_matching_teams_score_mode_renders_team_columns(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""``matching_method='score'`` on team search → score columns."""
srv, fake_db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_score_payload
)
res = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "X",
"competences": ["A"],
"matching_method": "score",
},
)
# Score-mode + team column layout (spec: 6.6 / 8.4).
for header in (
"Team Name",
"Schwerpunkt",
"Top-Kompetenzen",
"Role Score",
"Competence Score",
"Overall Score",
"Category",
):
assert header in res, f"missing team-search header {header!r}"
assert "Begründung" not in res
meta = _extract_meta(res)
assert meta.get("search_type") == "team_search"
assert meta.get("matching_method") == "score"
# The team loader was hit; the capacity loader was not.
assert fake_db.team_calls == 1
assert fake_db.capacity_calls == 0
# The mocked LLM may be invoked by the score path's role-similarity
# cache, but that is not relevant here. We only assert the routing
# decision via META and the column layout.
_ = llm_calls
@pytest.mark.asyncio
async def test_find_matching_teams_llm_mode_invokes_llm_matcher(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""``matching_method='llm_fulltext'`` on team search uses the LLM."""
srv, fake_db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_llm_payload
)
res = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "X",
"competences": ["A"],
"matching_method": "llm_fulltext",
},
)
# LLM matcher was actually invoked for the single candidate team.
assert llm_calls["count"] >= 1, "LLM matcher path was not exercised"
# META tags the LLM mode and the team search type.
meta = _extract_meta(res)
assert meta.get("search_type") == "team_search"
assert meta.get("matching_method") == "llm_fulltext"
# LLM-mode + team column layout (spec: 7.8 / 8.4).
for header in (
"Team Name",
"Schwerpunkt",
"Top-Kompetenzen",
"Category",
"Begründung",
):
assert header in res, f"missing team-search llm header {header!r}"
# Score columns must be absent in LLM mode.
assert "| Role Score |" not in res
assert "| Competence Score |" not in res
assert "| Overall Score |" not in res
# Team loader was hit; capacity loader was not.
assert fake_db.team_calls == 1
assert fake_db.capacity_calls == 0
@pytest.mark.asyncio
async def test_find_matching_teams_bogus_method_returns_error(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
"""Invalid ``matching_method`` on team search → error, no DB/LLM."""
srv, fake_db, llm_calls = _build_server_with(
monkeypatch, tmp_path, chat_completion=_fail_if_called
)
res = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "X",
"competences": ["A"],
"matching_method": "bogus",
},
)
lowered = res.lower()
assert "error" in lowered or "invalid" in lowered, res
assert "score" in res
assert "llm_fulltext" in res
# No side effects: the team loader and the LLM are untouched.
assert fake_db.team_calls == 0, (
"Team loader was hit despite invalid matching_method"
)
assert llm_calls["count"] == 0, (
"LLM was called despite invalid matching_method"
)
@@ -0,0 +1,203 @@
# Feature: llm-fulltext-matching, Property 11: META enthält das verwendete Verfahren
"""Property test verifying that META-JSON includes ``matching_method``.
The MCP server emits a ``META=...`` line in the response of
``find_matching_capacities`` and ``find_matching_tasks`` containing a
JSON object with ``search_id``, ``filter_id``, ``default_category`` and
``matching_method``. For any successful tool call, the value of
``matching_method`` in this JSON must be exactly the method that was
used (``"score"`` or ``"llm_fulltext"``).
Hypothesis would only enumerate two distinct values here, so the
property is encoded as a parametrized pytest test which still rebuilds
the server per case for full isolation. The header retains the
"Property 11" tag for traceability with the spec.
**Validates: Requirements 1.6, 9.5**
"""
from __future__ import annotations
import json
from datetime import date
from typing import Any
from unittest.mock import AsyncMock
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
class _FakeDB:
"""Minimal fake DB that returns one matching capacity."""
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return [
Capacity(
id=1,
owner_name="A",
role_name="X",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=["A"],
)
]
def get_recent_free_capacities(self, limit: int = 20):
return []
# LLM-fulltext path needs the batch description/cert/ref methods.
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META=") :])
raise AssertionError(f"no META= line in tool output:\n{output}")
@pytest.fixture
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["score", "llm_fulltext"])
async def test_meta_includes_matching_method(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
_env,
method: str,
) -> None:
"""Property 11: META carries the matching_method actually used."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
)
# A single payload that satisfies both consumers:
# - score path reads ``similarity``
# - llm_fulltext path reads ``category``/``rationale``
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
AsyncMock(
return_value=(
'{"similarity": 1.0, "category": "Top", "rationale": "ok"}'
)
),
)
srv = build_server(str(cfg))
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"matching_method": method,
},
)
meta = _extract_meta(res)
assert meta.get("matching_method") == method, (
f"expected matching_method={method!r}, got META={meta!r}"
)
@@ -0,0 +1,30 @@
from __future__ import annotations
from teamlandkarte_mcp.cache.search_cache import SearchCache
def test_multi_step_filter_ids_increment_under_same_search_id() -> None:
cache = SearchCache(ttl_minutes=60, max_size=10)
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "X", "competences": ["A"]},
results={"Top": [1, 2, 3], "Good": [], "Partial": [], "Low": []},
)
f1 = cache.add_filter(
search_id,
filtered_results={"Top": [1], "Good": [], "Partial": [], "Low": []},
filter_meta={"role_filter": "x"},
)
f2 = cache.add_filter(
search_id,
filtered_results={"Top": [1], "Good": [], "Partial": [], "Low": []},
filter_meta={"competence_filter": ["A"]},
)
assert f1 == "filter-1"
assert f2 == "filter-2"
entry = cache.get(search_id)
assert entry is not None
assert set(entry.filters.keys()) == {"filter-1", "filter-2"}
@@ -0,0 +1,139 @@
# Feature: llm-fulltext-matching, Property 3: Kategorienormalisierung bildet auf erlaubte Menge ab
"""Property-based tests for ``normalize_category``.
These tests cover the behaviour of
:func:`teamlandkarte_mcp.matching.llm_fulltext_matcher.normalize_category`
across a wide range of inputs. They verify three core properties:
1. **Output set membership for arbitrary text**: for any string input the
returned ``category`` is always one of the canonical categories
``("Top", "Good", "Partial", "Low", "Irrelevant")`` and ``is_valid`` is
a ``bool`` that correctly reflects whether the trimmed/lower-cased
input matches a known alias.
2. **Alias acceptance with random casing and whitespace**: any canonical
category transformed by random casing of its characters together with
leading/trailing whitespace must round-trip to the canonical spelling
with ``is_valid == True``.
3. **Non-string inputs are invalid**: ``None``, integers, floats, lists
and dictionaries always normalize to ``("Irrelevant", False)``.
**Validates: Requirements 5.3, 5.6, 6.3, 6.6**
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import normalize_category
_ALLOWED = ("Top", "Good", "Partial", "Low", "Irrelevant")
_ALLOWED_LOWER = tuple(c.lower() for c in _ALLOWED)
@settings(max_examples=100, deadline=None)
@given(value=st.text())
def test_normalize_category_output_in_allowed_set_for_arbitrary_text(
value: str,
) -> None:
"""Property 3 (arbitrary text): output is always in the allowed set.
For any string ``value`` the returned ``category`` must be one of the
canonical categories and ``is_valid`` must be ``True`` exactly when
``value.strip().lower()`` matches one of the canonical aliases.
"""
category, is_valid = normalize_category(value)
# The category is always in the canonical set.
assert category in _ALLOWED, (
f"category {category!r} not in allowed set for input {value!r}"
)
# `is_valid` is a real bool, not a truthy proxy.
assert isinstance(is_valid, bool)
normalized = value.strip().lower()
if normalized in _ALLOWED_LOWER:
# Known alias: must be accepted and mapped to canonical spelling.
assert is_valid is True, (
f"expected is_valid=True for alias {value!r}, got False"
)
# Canonical spelling capitalises the first letter.
expected = normalized.capitalize()
assert category == expected, (
f"expected canonical spelling {expected!r} for alias {value!r}, "
f"got {category!r}"
)
else:
# Anything else: invalid input falls back to Irrelevant.
assert is_valid is False, (
f"expected is_valid=False for non-alias {value!r}, got True"
)
assert category == "Irrelevant"
@st.composite
def _aliased_category(draw: st.DrawFn) -> tuple[str, str]:
"""Build a (canonical, transformed) pair with random casing + whitespace.
The transformed string keeps the canonical letters in order but mixes
upper- and lower-case per character and adds short leading/trailing
whitespace. ``normalize_category`` must still recover the canonical
spelling.
"""
canonical = draw(st.sampled_from(_ALLOWED))
chars: list[str] = []
for ch in canonical:
upper = draw(st.booleans())
chars.append(ch.upper() if upper else ch.lower())
body = "".join(chars)
leading = draw(st.text(alphabet=" \t", max_size=4))
trailing = draw(st.text(alphabet=" \t", max_size=4))
return canonical, leading + body + trailing
@settings(max_examples=100, deadline=None)
@given(pair=_aliased_category())
def test_normalize_category_recognizes_aliases_with_random_casing_and_whitespace(
pair: tuple[str, str],
) -> None:
"""Property 3 (aliases): canonical categories survive casing/whitespace.
For any canonical category transformed by random casing of its
characters and surrounded by tab/space whitespace, the function must
return the canonical spelling together with ``is_valid == True``.
"""
canonical, raw = pair
category, is_valid = normalize_category(raw)
assert is_valid is True, (
f"expected is_valid=True for transformed alias {raw!r}, got False"
)
assert category == canonical, (
f"expected canonical spelling {canonical!r} for {raw!r}, got {category!r}"
)
@settings(max_examples=100, deadline=None)
@given(
value=st.one_of(
st.none(),
st.integers(),
st.lists(st.text(), max_size=3),
st.dictionaries(st.text(), st.text(), max_size=3),
st.floats(allow_nan=False),
)
)
def test_normalize_category_non_string_inputs_are_invalid(value: object) -> None:
"""Property 3 (non-string): non-string inputs always return Irrelevant.
For any non-string input (``None``, int, float, list, dict)
``normalize_category`` must return exactly ``("Irrelevant", False)``.
"""
category, is_valid = normalize_category(value)
assert category == "Irrelevant", (
f"expected 'Irrelevant' for non-string input {value!r}, got {category!r}"
)
assert is_valid is False, (
f"expected is_valid=False for non-string input {value!r}, got True"
)
assert isinstance(is_valid, bool)
@@ -0,0 +1,143 @@
from __future__ import annotations
import pytest
from teamlandkarte_mcp.cache.search_cache import SearchCache
def test_search_cache_pagination_slice() -> None:
"""Sanity-check pagination math used by get_results_by_category.
This test does not execute the MCP tool. It validates the expected slice
behavior that the tool should apply: 1-based pages and stable page sizes.
"""
cache = SearchCache(ttl_minutes=60, max_size=10)
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "X", "competences": ["A"]},
results={"Top": list(range(1, 51))},
)
entry = cache.get(search_id)
assert entry is not None
items = entry.results["Top"]
page_size = 20
page1 = items[(1 - 1) * page_size : 1 * page_size]
page2 = items[(2 - 1) * page_size : 2 * page_size]
page3 = items[(3 - 1) * page_size : 3 * page_size]
assert page1 == list(range(1, 21))
assert page2 == list(range(21, 41))
assert page3 == list(range(41, 51))
@pytest.mark.parametrize(
"page,page_size,expected",
[
(1, 10, list(range(1, 11))),
(2, 10, list(range(11, 21))),
(3, 10, list(range(21, 31))),
],
)
def test_pagination_param_matrix(
page: int, page_size: int, expected: list[int]
) -> None:
items = list(range(1, 31))
got = items[(page - 1) * page_size : page * page_size]
assert got == expected
# ---------------------------------------------------------------------------
# Team-search pagination coverage (Profile_Type="team")
# ---------------------------------------------------------------------------
#
# Pagination is search-type-agnostic: ``get_results_by_category``
# slices ``entry.results["by_category"][cat]`` regardless of whether
# the entry is a ``capacity_search`` or a ``team_search``. The tests
# below exercise the same slicing logic for ``team_search`` payloads
# in both ``matching_method`` flavours so the routing/pagination
# surface is covered for both ``Profile_Type`` values.
#
# _Requirements: 8.1, 8.4, 12.7_
def _team_item(idx: int, *, matching_method: str) -> dict:
"""Build a minimal cached team item for pagination assertions."""
base = {
"team_id": f"t{idx}",
"ouid": f"ou-{idx}",
"team_name": f"Team {idx}",
"focus_name": "Backend Developer",
"about_us": "",
"offerings": "",
"interests": "",
"competences": [],
"references": [],
"category": "Top",
}
if matching_method == "score":
return {
**base,
"competence_score": 1.0,
"role_score": 1.0,
"overall_score": 1.0,
}
return {**base, "rationale": f"rationale {idx}"}
@pytest.mark.parametrize("matching_method", ["score", "llm_fulltext"])
def test_team_search_pagination_slice(matching_method: str) -> None:
"""Slicing of a ``team_search`` ``by_category`` bucket is identical
to the capacity-search slicing math (1-based pages, stable size).
"""
cache = SearchCache(ttl_minutes=60, max_size=10)
items = [_team_item(i, matching_method=matching_method)
for i in range(1, 51)]
payload = {
"search_type": "team_search",
"matching_method": matching_method,
"reference": {
"availability_date_start": None,
"availability_date_end": None,
},
"summary": {"Top": len(items)},
"by_category": {
"Top": items,
"Good": [],
"Partial": [],
"Low": [],
"Irrelevant": [],
},
}
if matching_method == "llm_fulltext":
payload["errors"] = []
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "Backend Developer",
"competences": ["python"]},
results=payload,
)
entry = cache.get(search_id)
assert entry is not None
assert entry.results["search_type"] == "team_search"
assert entry.results["matching_method"] == matching_method
bucket = entry.results["by_category"]["Top"]
page_size = 20
page1 = bucket[(1 - 1) * page_size : 1 * page_size]
page2 = bucket[(2 - 1) * page_size : 2 * page_size]
page3 = bucket[(3 - 1) * page_size : 3 * page_size]
assert [it["team_id"] for it in page1] == [f"t{i}" for i in range(1, 21)]
assert [it["team_id"] for it in page2] == [f"t{i}" for i in range(21, 41)]
assert [it["team_id"] for it in page3] == [f"t{i}" for i in range(41, 51)]
# Page 3 has the partial-tail (10 items) like the capacity case.
assert len(page3) == 10
@@ -0,0 +1,186 @@
# Feature: remove-embedding-competence-similarity, Property 4: Graceful Degradation bei LLM-Fehler
"""Property-based tests for graceful degradation on LLM failure."""
from __future__ import annotations
import json
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
# Strategy: role name strings (ASCII letters only, 1-50 chars,
# no "(unknown)")
_role_str = st.text(
alphabet=st.characters(
whitelist_categories=("L",),
max_codepoint=127,
),
min_size=1,
max_size=50,
).filter(lambda s: s.strip().lower() != "(unknown)")
# Strategy: non-empty task text (ASCII letters + spaces, 1-100 chars)
_task_text = st.text(
alphabet=st.characters(
whitelist_categories=("L", "Zs"),
max_codepoint=127,
),
min_size=1,
max_size=100,
).filter(lambda s: s.strip())
# Strategy: non-empty role names for DB list (ASCII letters, 3-30 chars)
_role_name = st.text(
alphabet=st.characters(
whitelist_categories=("L",),
max_codepoint=127,
),
min_size=3,
max_size=30,
)
# Strategy: list of unique role names (1-10 roles)
_role_list = st.lists(_role_name, min_size=1, max_size=10, unique=True)
# Strategy: exception class to raise
_exception_class = st.sampled_from([TimeoutError, RuntimeError, ValueError])
class _FailingClient:
"""Stub AzureOpenAIClient that raises a configured exception."""
def __init__(self, exc_class: type) -> None:
self._exc_class = exc_class
async def chat_completion(self, system: str, user: str) -> str:
if self._exc_class is ValueError:
raise ValueError("simulated LLM failure")
elif self._exc_class is json.JSONDecodeError:
raise json.JSONDecodeError("simulated", "", 0)
else:
raise self._exc_class("simulated LLM failure")
class _JSONDecodeErrorClient:
"""Stub that always raises json.JSONDecodeError."""
async def chat_completion(self, system: str, user: str) -> str:
raise json.JSONDecodeError("simulated parse error", "", 0)
class _FakeDB:
"""Stub DBClient that returns a configurable role list."""
def __init__(self, roles: list[str]) -> None:
self._roles = roles
def get_all_role_names(self) -> list[str]:
return self._roles
# **Validates: Requirements 5.6**
@settings(max_examples=100)
@given(role_a=_role_str, role_b=_role_str, exc_class=_exception_class)
@pytest.mark.asyncio
async def test_compute_role_similarity_returns_zero_on_exception(
role_a: str, role_b: str, exc_class: type
) -> None:
"""Property 4a: compute_role_similarity returns 0.0 on any LLM exception.
For any two valid role names and any exception type raised by the LLM,
compute_role_similarity shall return 0.0 without propagating the
exception to the caller.
"""
# Skip identical roles (they short-circuit to 1.0 without LLM call)
if role_a.strip().lower() == role_b.strip().lower():
return
client = _FailingClient(exc_class=exc_class)
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
result = await engine.compute_role_similarity(role_a, role_b)
assert result == 0.0, (
f"Expected 0.0 on {exc_class.__name__}, got {result} "
f"(roles: {role_a!r}, {role_b!r})"
)
# **Validates: Requirements 5.6**
@settings(max_examples=100)
@given(role_a=_role_str, role_b=_role_str)
@pytest.mark.asyncio
async def test_compute_role_similarity_returns_zero_on_json_decode_error(
role_a: str, role_b: str,
) -> None:
"""Property 4b: compute_role_similarity returns 0.0 on JSONDecodeError.
json.JSONDecodeError requires positional args, so it is tested
separately with a dedicated stub.
"""
# Skip identical roles (they short-circuit to 1.0 without LLM call)
if role_a.strip().lower() == role_b.strip().lower():
return
client = _JSONDecodeErrorClient()
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
result = await engine.compute_role_similarity(role_a, role_b)
assert result == 0.0, (
f"Expected 0.0 on JSONDecodeError, got {result} "
f"(roles: {role_a!r}, {role_b!r})"
)
# **Validates: Requirements 3.5**
@settings(max_examples=100)
@given(task_text=_task_text, roles=_role_list, exc_class=_exception_class)
@pytest.mark.asyncio
async def test_infer_primary_role_returns_none_on_exception(
task_text: str, roles: list[str], exc_class: type
) -> None:
"""Property 4c: infer_primary_role returns None on any LLM exception.
For any valid task text and role list, if the LLM raises any exception,
infer_primary_role shall return None without propagating the exception
to the caller.
"""
client = _FailingClient(exc_class=exc_class)
db = _FakeDB(roles=roles)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_primary_role(task_text=task_text)
assert result is None, (
f"Expected None on {exc_class.__name__}, got {result} "
f"(task_text: {task_text!r})"
)
# **Validates: Requirements 3.5**
@settings(max_examples=100)
@given(task_text=_task_text, roles=_role_list)
@pytest.mark.asyncio
async def test_infer_primary_role_returns_none_on_json_decode_error(
task_text: str, roles: list[str],
) -> None:
"""Property 4d: infer_primary_role returns None on JSONDecodeError.
json.JSONDecodeError requires positional args, so it is tested
separately with a dedicated stub.
"""
client = _JSONDecodeErrorClient()
db = _FakeDB(roles=roles)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_primary_role(task_text=task_text)
assert result is None, (
f"Expected None on JSONDecodeError, got {result} "
f"(task_text: {task_text!r})"
)
@@ -0,0 +1,142 @@
# Feature: remove-embedding-competence-similarity, Property 3: infer_primary_role Ausgabe-Validität
"""Property-based tests for infer_primary_role output validity."""
from __future__ import annotations
import json
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
# Strategy: non-empty task text (ASCII letters + spaces, 1-100 chars)
_task_text = st.text(
alphabet=st.characters(
whitelist_categories=("L", "Zs"),
max_codepoint=127,
),
min_size=1,
max_size=100,
).filter(lambda s: s.strip())
# Strategy: non-empty role names (ASCII letters, 3-30 chars)
_role_name = st.text(
alphabet=st.characters(
whitelist_categories=("L",),
max_codepoint=127,
),
min_size=3,
max_size=30,
)
# Strategy: list of unique role names (1-10 roles)
_role_list = st.lists(_role_name, min_size=1, max_size=10, unique=True)
# Strategy: confidence score returned by the LLM (includes out-of-range
# values to verify clamping)
_llm_confidence = st.floats(min_value=-1.0, max_value=2.0)
class _FakeDB:
"""Stub DBClient that returns a configurable role list."""
def __init__(self, roles: list[str]) -> None:
self._roles = roles
def get_all_role_names(self) -> list[str]:
return self._roles
class _FakeClient:
"""Stub AzureOpenAIClient that returns a chosen role and confidence."""
def __init__(self, role: str, confidence: float) -> None:
self._role = role
self._confidence = confidence
async def chat_completion(self, system: str, user: str) -> str:
return json.dumps({"role": self._role, "confidence": self._confidence})
# **Validates: Requirements 3.1, 3.4**
@settings(max_examples=100)
@given(
task_text=_task_text,
roles=_role_list,
confidence=_llm_confidence,
role_index=st.data(),
)
@pytest.mark.asyncio
async def test_infer_primary_role_output_in_role_list(
task_text: str,
roles: list[str],
confidence: float,
role_index: st.DataObject,
) -> None:
"""Property 3a: If infer_primary_role returns non-None, the role is in the DB list
and confidence is in [0.0, 1.0].
For any non-empty task text and non-empty role list, if the LLM returns
a valid role from the list, the output must satisfy:
- role_name is an element of the database role list
- confidence is a float in [0.0, 1.0]
"""
# Pick a random role from the generated list for the LLM to "return"
idx = role_index.draw(st.integers(min_value=0, max_value=len(roles) - 1))
chosen_role = roles[idx]
db = _FakeDB(roles=roles)
client = _FakeClient(role=chosen_role, confidence=confidence)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_primary_role(task_text=task_text)
assert result is not None, (
f"Expected non-None result for valid role {chosen_role!r} in list"
)
role_name, conf = result
assert role_name in roles, (
f"Returned role {role_name!r} not in DB role list {roles!r}"
)
assert isinstance(conf, float), (
f"Expected float confidence, got {type(conf)}"
)
assert 0.0 <= conf <= 1.0, (
f"Confidence {conf} out of range [0.0, 1.0] "
f"(llm_confidence: {confidence})"
)
# **Validates: Requirement 3.4 (role must be in DB list)**
@settings(max_examples=100)
@given(
task_text=_task_text,
roles=_role_list,
)
@pytest.mark.asyncio
async def test_infer_primary_role_rejects_unknown_role(
task_text: str,
roles: list[str],
) -> None:
"""Property 3b: If the LLM returns a role NOT in the DB list, result is None.
The VocabularyCache must validate that the returned role exists in the
database role list. If it doesn't, the function returns None.
"""
# Return a role that is guaranteed not to be in the list
fake_role = "ZZZZZ_NOT_A_REAL_ROLE_99999"
assert fake_role not in roles
db = _FakeDB(roles=roles)
client = _FakeClient(role=fake_role, confidence=0.9)
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
result = await cache.infer_primary_role(task_text=task_text)
assert result is None, (
f"Expected None when LLM returns unknown role {fake_role!r}, "
f"got {result!r}"
)
@@ -0,0 +1,193 @@
# Feature: remove-embedding-competence-similarity, Property 2: Matcher baut BM25-Index bedingungslos
"""Property-based tests for unconditional BM25 index building in Matcher.
For any non-empty list of filtered candidates with competences, the Matcher
shall always build a global BM25 index and use it for competence scoring,
producing results where candidates with no token overlap receive score 0.0.
"""
from __future__ import annotations
from datetime import date
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.config import MatchingConfig
from teamlandkarte_mcp.matching.matcher import Matcher
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
from teamlandkarte_mcp.models import Capacity, Requirements
class _DummyClient:
"""Minimal stub for AzureOpenAIClient."""
async def chat_completion(self, system: str, user: str) -> str:
return '{"similarity": 0.5}'
def _make_matcher() -> Matcher:
engine = SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type]
cfg = MatchingConfig()
return Matcher(similarity=engine, cfg=cfg)
# Strategy: ASCII-only letters to avoid Unicode tokenization edge cases.
# The BM25 tokenizer lowercases then splits on [\W_]+. Some Unicode letters
# produce non-word characters when lowercased (e.g. İ -> i + combining dot),
# which would create unexpected token boundaries and shared tokens.
_ascii_letters = st.text(
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz"),
min_size=3,
max_size=15,
)
_competence_list = st.lists(_ascii_letters, min_size=1, max_size=4)
@st.composite
def _capacity_strategy(draw: st.DrawFn) -> Capacity:
"""Generate a Capacity with competences prefixed by 'cand'."""
comps = draw(_competence_list)
prefixed = [f"cand{c}" for c in comps]
return Capacity(
id=draw(st.integers(min_value=1, max_value=10000)),
owner_name="TestOwner",
role_name="TestRole",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=prefixed,
)
# **Validates: Requirements 2.6**
@settings(max_examples=100)
@given(
capacities=st.lists(_capacity_strategy(), min_size=1, max_size=5),
required_comps=_competence_list,
)
@pytest.mark.asyncio
async def test_matcher_builds_bm25_index_unconditionally(
capacities: list[Capacity],
required_comps: list[str],
) -> None:
"""Property 2: Matcher builds BM25 index unconditionally for all filtered
candidates.
For any non-empty list of filtered candidates, the Matcher always builds
a global BM25 index. When required competences have no token overlap with
any candidate competence, the competence score must be 0.0.
"""
# Prefix required competences with "req" to guarantee disjoint tokens.
# Since all generated strings are pure ASCII lowercase letters, and
# "req" != "cand", the tokens "req<x>" and "cand<y>" can never overlap.
disjoint_required = [f"req{c}" for c in required_comps]
requirements = Requirements(
role_name="TestRole",
competences=disjoint_required,
date_start=date(2025, 1, 1),
date_end=date(2025, 12, 31),
)
matcher = _make_matcher()
result = await matcher.match(capacities, requirements)
# All candidates should be in the result (dates overlap)
assert len(result.scored) == len(capacities)
# Since required competences (req*) have no token overlap with
# candidate competences (cand*), all competence scores must be 0.0
for scored in result.scored:
assert scored.competence_score == 0.0, (
f"Expected competence_score 0.0 for capacity {scored.capacity.id}, "
f"got {scored.competence_score}. "
f"Required: {disjoint_required}, "
f"Candidate: {scored.capacity.competences}"
)
assert set(scored.missing_competences) == set(disjoint_required), (
f"Expected all required competences to be missing, "
f"got missing={scored.missing_competences}"
)
@st.composite
def _capacities_with_unique_token(draw: st.DrawFn) -> list[Capacity]:
"""Generate 3 capacities where the first has a unique competence.
BM25 IDF = log((N - df + 0.5) / (df + 0.5)). With N=2 and df=1,
IDF = log(1) = 0. We need N >= 3 with df=1 for IDF > 0.
We generate 3 capacities with 3 distinct competences in the global
corpus. The unique token appears only in cap1 (df=1, N=3 → IDF > 0).
"""
base = draw(_ascii_letters)
other = f"other{draw(_ascii_letters)}"
unique_token = f"zzuniq{draw(_ascii_letters)}"
cap1 = Capacity(
id=1,
owner_name="TestOwner",
role_name="TestRole",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=[base, unique_token],
)
cap2 = Capacity(
id=2,
owner_name="TestOwner",
role_name="TestRole",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=[base],
)
cap3 = Capacity(
id=3,
owner_name="TestOwner",
role_name="TestRole",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=[other],
)
return [cap1, cap2, cap3]
@settings(max_examples=50)
@given(capacities=_capacities_with_unique_token())
@pytest.mark.asyncio
async def test_matcher_scores_nonzero_for_token_overlap(
capacities: list[Capacity],
) -> None:
"""Property 2 (supplementary): When the BM25 index is built and a required
competence shares tokens with a candidate, the score is non-zero.
This confirms the index is actually built and used for scoring (not
skipped). BM25 IDF is positive when a term appears in fewer than half
the corpus documents. With 3+ corpus docs and df=1, IDF > 0.
"""
unique_comp = capacities[0].competences[1]
requirements = Requirements(
role_name="TestRole",
competences=[unique_comp],
date_start=date(2025, 1, 1),
date_end=date(2025, 12, 31),
)
matcher = _make_matcher()
result = await matcher.match(capacities, requirements)
assert len(result.scored) == len(capacities)
# Cap1 must have non-zero competence score (it has the unique token)
first_cap_scored = next(
s for s in result.scored if s.capacity.id == capacities[0].id
)
assert first_cap_scored.competence_score > 0.0, (
f"Expected non-zero competence score for unique token match, "
f"got {first_cap_scored.competence_score}. "
f"Required: [{unique_comp}], Candidate: {capacities[0].competences}"
)
@@ -0,0 +1,119 @@
# Feature: remove-embedding-competence-similarity, Property 6: Rollen-Similarity-Cache ist symmetrisch und idempotent
"""Property-based tests for role similarity cache symmetry and idempotency."""
from __future__ import annotations
import json
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
# Strategy: role name strings (ASCII letters only, 1-50 chars,
# no "(unknown)")
_role_str = st.text(
alphabet=st.characters(
whitelist_categories=("L",),
max_codepoint=127,
),
min_size=1,
max_size=50,
).filter(lambda s: s.strip().lower() != "(unknown)")
class _DummyClient:
"""Stub that returns a fixed similarity score and tracks call count."""
def __init__(self, score: float = 0.72) -> None:
self._score = score
self.call_count = 0
async def chat_completion(self, system: str, user: str) -> str:
self.call_count += 1
return json.dumps({"similarity": self._score})
# **Validates: Requirements 5.8**
@settings(max_examples=100)
@given(role_a=_role_str, role_b=_role_str)
@pytest.mark.asyncio
async def test_role_similarity_cache_symmetry(
role_a: str, role_b: str,
) -> None:
"""Property 6a: Cache is symmetric.
For any role pair (A, B) where A != B (case-insensitive),
compute_role_similarity(A, B) == compute_role_similarity(B, A),
and the second call shall not invoke the LLM (cache hit).
"""
# Skip identical roles (they short-circuit to 1.0 without LLM)
if role_a.strip().lower() == role_b.strip().lower():
return
client = _DummyClient(score=0.72)
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
# First call: A, B
score_ab = await engine.compute_role_similarity(role_a, role_b)
# Second call: B, A (should use cache)
score_ba = await engine.compute_role_similarity(role_b, role_a)
# Symmetry: both calls return the same score
assert score_ab == score_ba, (
f"Symmetry violated: "
f"sim({role_a!r}, {role_b!r})={score_ab} != "
f"sim({role_b!r}, {role_a!r})={score_ba}"
)
# Only one LLM call should have been made (second was a cache hit)
assert client.call_count == 1, (
f"Expected 1 LLM call but got {client.call_count} "
f"(roles: {role_a!r}, {role_b!r})"
)
# **Validates: Requirements 5.8**
@settings(max_examples=100)
@given(role_a=_role_str, role_b=_role_str)
@pytest.mark.asyncio
async def test_role_similarity_cache_idempotent(
role_a: str, role_b: str,
) -> None:
"""Property 6b: Cache is idempotent.
Repeated calls with the same role pair always return the same
cached value without additional LLM calls.
"""
# Skip identical roles (they short-circuit to 1.0 without LLM)
if role_a.strip().lower() == role_b.strip().lower():
return
client = _DummyClient(score=0.65)
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
# First call populates the cache
score_1 = await engine.compute_role_similarity(role_a, role_b)
assert client.call_count == 1
# Second call with same order: cache hit
score_2 = await engine.compute_role_similarity(role_a, role_b)
assert score_2 == score_1, (
f"Idempotency violated: call 1={score_1}, call 2={score_2}"
)
assert client.call_count == 1, (
f"Expected 1 LLM call after 2 same-order calls, "
f"got {client.call_count}"
)
# Third call with reversed order: also cache hit
score_3 = await engine.compute_role_similarity(role_b, role_a)
assert score_3 == score_1, (
f"Idempotency violated on reverse: "
f"call 1={score_1}, call 3={score_3}"
)
assert client.call_count == 1, (
f"Expected 1 LLM call after 3 total calls, "
f"got {client.call_count}"
)
@@ -0,0 +1,104 @@
# Feature: remove-embedding-competence-similarity, Property 5: compute_role_similarity Wertebereich
"""Property-based tests for compute_role_similarity value range."""
from __future__ import annotations
import json
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
# Strategy: role name strings (ASCII letters only, 1-50 chars,
# no "(unknown)")
_role_str = st.text(
alphabet=st.characters(
whitelist_categories=("L",),
whitelist_characters="",
max_codepoint=127,
),
min_size=1,
max_size=50,
).filter(lambda s: s.strip().lower() != "(unknown)")
# Strategy: similarity score returned by the LLM (intentionally includes
# out-of-range values to verify clamping behaviour)
_llm_score = st.floats(min_value=-1.0, max_value=2.0)
class _DummyClient:
"""Stub that returns a configurable similarity score."""
def __init__(self, score: float = 0.5) -> None:
self._score = score
async def chat_completion(self, system: str, user: str) -> str:
return json.dumps({"similarity": self._score})
# **Validates: Requirement 5.1 (value range [0.0, 1.0])**
@settings(max_examples=100)
@given(role_a=_role_str, role_b=_role_str, score=_llm_score)
@pytest.mark.asyncio
async def test_role_similarity_always_in_unit_interval(
role_a: str, role_b: str, score: float
) -> None:
"""Property 5a: compute_role_similarity always returns a value in [0.0, 1.0].
For any two non-empty, non-"(unknown)" role names, the returned
similarity must lie in the closed interval [0.0, 1.0], regardless of
what the underlying LLM returns (clamping).
"""
client = _DummyClient(score=score)
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
result = await engine.compute_role_similarity(role_a, role_b)
assert isinstance(result, float), (
f"Expected float, got {type(result)}"
)
assert 0.0 <= result <= 1.0, (
f"Score {result} out of range [0.0, 1.0] "
f"(roles: {role_a!r}, {role_b!r}, llm_score: {score})"
)
# **Validates: Requirement 5.1 (identity → 1.0)**
@settings(max_examples=100)
@given(role=_role_str)
@pytest.mark.asyncio
async def test_role_similarity_identity_returns_one(
role: str,
) -> None:
"""Property 5b: identical role names (case-insensitive) return 1.0.
The engine short-circuits without calling the LLM when both role
arguments normalize to the same string.
"""
class _FailingClient:
"""Client that fails if called (identity must short-circuit)."""
async def chat_completion(self, system: str, user: str) -> str:
raise AssertionError(
"LLM should not be called for identical roles"
)
engine = SimilarityEngine(client=_FailingClient()) # type: ignore[arg-type]
# Same role, same casing
result = await engine.compute_role_similarity(role, role)
assert result == 1.0, (
f"Expected 1.0 for identical role {role!r}, got {result}"
)
# Same role, different casing (upper vs lower)
result_mixed = await engine.compute_role_similarity(
role.upper(), role.lower()
)
assert result_mixed == 1.0, (
f"Expected 1.0 for case-insensitive match "
f"({role.upper()!r} vs {role.lower()!r}), got {result_mixed}"
)
@@ -0,0 +1,106 @@
# Feature: remove-embedding-competence-similarity, Property 1: BM25 Zero-Score für fehlenden Token-Overlap
"""Property-based tests for SimilarityEngine (BM25+RRF competence matching)."""
from __future__ import annotations
import re
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.bm25 import Bm25Index
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
class _DummyClient:
"""Minimal stub for AzureOpenAIClient (unused for competence similarity)."""
async def chat_completion(self, system: str, user: str) -> str:
return '{"similarity": 0.5}'
def _bm25_engine() -> SimilarityEngine:
return SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type]
def _tokenize(text: str) -> set[str]:
"""Mirror the BM25 tokenizer: lowercase + split on [\\W_]+."""
return {t for t in re.split(r"[\W_]+", text.lower()) if t}
# Strategy: generate competence strings using ASCII letters only (min 3 chars).
# We restrict to ASCII to avoid Unicode casefolding edge-cases (e.g. İ → i̇)
# that produce unexpected token splits and break the prefix-disjointness guarantee.
_competence_str = st.text(
alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127),
min_size=3,
max_size=30,
)
# Strategy: generate lists of competence strings (1-5 items)
_competence_list = st.lists(_competence_str, min_size=1, max_size=5)
def _make_disjoint_pair(
required: list[str], candidate: list[str]
) -> tuple[list[str], list[str]]:
"""Prefix-separate required and candidate to guarantee disjoint tokens.
Required competences get prefixed with 'req' and candidate competences
get prefixed with 'cand'. Since both prefixes are pure letters and
differ, and the original strings are pure letters, the resulting token
sets are guaranteed to be disjoint after the BM25 tokenizer processes them.
"""
prefixed_req = [f"req{s}" for s in required]
prefixed_cand = [f"cand{s}" for s in candidate]
return prefixed_req, prefixed_cand
# **Validates: Requirements 1.1**
@settings(max_examples=100)
@given(required=_competence_list, candidate=_competence_list)
@pytest.mark.asyncio
async def test_bm25_zero_score_on_disjoint_tokens(
required: list[str], candidate: list[str]
) -> None:
"""Property 1: BM25 returns 0.0 for all required competences when there is
no token overlap with any candidate competence.
For any set of required competences and candidate competences where no
candidate shares any token with a required competence,
compute_competence_similarity shall return a score of 0.0 for that
required competence.
"""
# Ensure disjoint tokens via prefix separation
disjoint_req, disjoint_cand = _make_disjoint_pair(required, candidate)
# Verify our disjoint guarantee holds (sanity check on the strategy)
req_tokens = set()
for r in disjoint_req:
req_tokens.update(_tokenize(r))
cand_tokens = set()
for c in disjoint_cand:
cand_tokens.update(_tokenize(c))
assert req_tokens.isdisjoint(cand_tokens), (
f"Tokens not disjoint: {req_tokens & cand_tokens}"
)
# Build a global BM25 index from the candidate competences (realistic usage)
global_index = Bm25Index(corpus=disjoint_cand)
engine = _bm25_engine()
result = await engine.compute_competence_similarity(
required=disjoint_req,
candidate=disjoint_cand,
global_index=global_index,
)
# All required competences must have score 0.0
for req_competence in disjoint_req:
assert req_competence in result, (
f"Missing key {req_competence!r} in result"
)
score = float(result[req_competence]["score"]) # type: ignore[arg-type]
assert score == 0.0, (
f"Expected 0.0 for {req_competence!r} but got {score}"
)
@@ -0,0 +1,235 @@
from __future__ import annotations
from datetime import datetime, date
import pytest
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity, Task
class _FakeDB:
def __init__(self, caps: list[Capacity], tasks: list[Task]) -> None:
self._caps = caps
self._tasks = tasks
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Cloud Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["AWS"]
def get_open_tasks(self, limit: int = 20) -> list[Task]:
if limit == 0:
return list(self._tasks)
return list(self._tasks)[:limit]
def get_task_by_id(self, task_id: str): # pragma: no cover
for t in self._tasks:
if t.id == task_id:
return t
return None
def get_task_by_name(self, name: str): # pragma: no cover
if not name:
return None
for t in self._tasks:
if getattr(t, "name", None) == name:
return t
return None
def get_all_capacities_with_competences(self): # pragma: no cover
return list(self._caps)
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
return list(self._caps)[:limit]
def get_capacity_by_id(self, capacity_id: int | str):
cid = int(capacity_id)
for c in self._caps:
if int(c.id) == cid:
return c
return None
def get_capacity_description(self, capacity_id: int | str) -> str | None:
return None
def get_capacity_references(self, capacity_id: int | str) -> list[dict]:
return []
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
return []
@pytest.mark.asyncio
async def test_list_free_capacities_empty_table(monkeypatch, tmp_path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: _FakeDB([], []))
srv = build_server(str(cfg))
content, structured = await srv.call_tool("list_free_capacities", {"limit": 5})
out = (structured or {}).get("result") or getattr(content[0], "text", "")
assert "| capacity_id |" in out
@pytest.mark.asyncio
async def test_get_capacity_details_next_steps(monkeypatch, tmp_path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
cap = Capacity(
id=1,
owner_name="Team",
role_name="Cloud Engineer",
role_level=None,
begin_date=date(2026, 1, 1),
end_date=date(2026, 1, 31),
competences=["AWS"],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: _FakeDB([cap], []))
srv = build_server(str(cfg))
content, structured = await srv.call_tool(
"get_capacity_details", {"capacity_id": 1}
)
out = (structured or {}).get("result") or getattr(content[0], "text", "")
assert "| capacity_id |" in out
assert "## Next steps" in out
assert "find_matching_tasks" in out
@pytest.mark.asyncio
async def test_find_matching_tasks_creates_search_id(monkeypatch, tmp_path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.inference]
max_competences=2
min_similarity=0.0
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
cap = Capacity(
id=1,
owner_name="Team",
role_name="Cloud Engineer",
role_level=None,
begin_date=None,
end_date=None,
competences=["AWS"],
)
task = Task(
id="t1",
name=None,
title="Task",
description="AWS work",
start_date=None,
end_date=None,
created_date=datetime(2026, 1, 1, 10, 0, 0),
skills=[],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: _FakeDB([cap], [task]))
srv = build_server(str(cfg))
content, structured = await srv.call_tool("find_matching_tasks", {"capacity_id": 1})
out = (structured or {}).get("result") or getattr(content[0], "text", "")
assert "SEARCH_ID=" in out
assert "## Summary" in out
assert "| task_id |" in out
@@ -0,0 +1,298 @@
# Feature: llm-fulltext-matching, Property 2: Leere/None-Felder verwerfen das Profil nicht
"""Property-based tests for empty/None handling in the profile builders.
These tests cover the profile builder helpers
``build_capacity_profile``, ``build_task_profile`` and
``build_task_profile_from_requirements`` defined in
``teamlandkarte_mcp.matching.profiles``.
The key invariant under test is that the builders **never discard a
profile**: empty strings, ``None`` values and empty lists in the input
must be mapped to empty strings or empty lists in the output, and the
deterministic serializers must still include every field heading.
In particular, references with an empty ``partner_name`` (NULL
``partner_id`` or join mismatch) are kept in the resulting
:class:`CapacityProfile` and only their partner token is suppressed in
the serialized output.
**Validates: Requirements 3.2, 3.4, 4.2**
"""
from __future__ import annotations
from datetime import datetime
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.profiles import (
CapacityProfile,
CapacityReferenceEntry,
TaskProfile,
build_capacity_profile,
build_task_profile,
build_task_profile_from_requirements,
serialize_capacity_profile,
serialize_task_profile,
)
from teamlandkarte_mcp.models import Capacity, Requirements, Task
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Optional text fields (model fields typed ``Optional[str]``): either
# ``None`` or a short text. The builders must coerce ``None`` and
# whitespace-only inputs to empty strings.
_maybe_text = st.one_of(st.none(), st.text(max_size=30))
# Optional list-of-strings inputs (e.g. extra description, certificates).
# The builders must coerce ``None`` to an empty list.
_maybe_text_list = st.one_of(
st.none(), st.lists(st.text(max_size=20), max_size=5)
)
# Partner name on DB rows: either empty (NULL ``partner_id`` or join
# mismatch, see requirement 2.8) or a short non-empty string.
_maybe_partner = st.one_of(
st.just(""), st.text(min_size=1, max_size=20)
)
# A single ``CapacityReferenceRow`` (TypedDict) as returned by the
# ``DBClient``. ``projects`` is constrained to non-whitespace text so the
# builder never drops the row defensively; this lets us assert the
# stronger invariant "input row count == output entry count".
_ref_dict = st.fixed_dictionaries(
{
"partner_name": _maybe_partner,
"projects": st.text(min_size=1, max_size=30).filter(
lambda s: s.strip() != ""
),
}
)
_maybe_ref_list = st.one_of(st.none(), st.lists(_ref_dict, max_size=5))
# ``Capacity`` instances with empty/None-heavy fields. ``begin_date`` and
# ``end_date`` are pinned to ``None`` to keep the strategy fast and
# deterministic; the builder ignores them anyway.
_capacity = st.builds(
Capacity,
id=st.integers(min_value=0, max_value=10**9),
owner_name=st.one_of(st.just(""), st.text(max_size=30)),
role_name=_maybe_text,
role_level=_maybe_text,
begin_date=st.none(),
end_date=st.none(),
competences=st.one_of(
st.just([]), st.lists(st.text(max_size=20), max_size=5)
),
)
# ``Task`` instances with empty/None-heavy fields. ``created_date`` is
# pinned to a fixed value (the dataclass requires a ``datetime``).
_task = st.builds(
Task,
id=st.text(min_size=1, max_size=20),
name=_maybe_text,
title=st.one_of(st.just(""), st.text(max_size=30)),
description=st.one_of(st.just(""), st.text(max_size=30)),
start_date=st.none(),
end_date=st.none(),
created_date=st.just(datetime(2024, 1, 1)),
skills=st.one_of(
st.just([]), st.lists(st.text(max_size=20), max_size=5)
),
)
# ``Requirements`` instances with empty/None-heavy fields.
_requirements = st.builds(
Requirements,
role_name=_maybe_text,
competences=st.one_of(
st.just([]), st.lists(st.text(max_size=20), max_size=5)
),
date_start=st.none(),
date_end=st.none(),
description=_maybe_text,
)
_CAPACITY_HEADINGS = (
"Rolle:",
"Kompetenzen:",
"Beschreibung:",
"Referenzen:",
"Zertifikate:",
)
_TASK_HEADINGS = (
"Titel:",
"Beschreibung:",
"Gesuchte Kompetenzen:",
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(
capacity=_capacity,
description=_maybe_text,
certificates=_maybe_text_list,
references=_maybe_ref_list,
)
def test_build_capacity_profile_handles_empty_or_none_fields(
capacity: Capacity,
description: str | None,
certificates: list[str] | None,
references: list[dict] | None,
) -> None:
"""Property 2 (Capacity): empty/None inputs never discard the profile.
For any combination of ``None``/empty/whitespace inputs (including
references with empty ``partner_name``), ``build_capacity_profile``
must return a fully populated :class:`CapacityProfile` whose string
fields are real strings (never ``None``) and whose list fields are
real lists (never ``None``). References with non-whitespace
``projects`` are kept regardless of the partner name, and the
deterministic serialization still contains every field heading.
"""
profile = build_capacity_profile(
capacity,
description=description,
certificates=certificates,
references=references,
)
# Profile is never discarded.
assert isinstance(profile, CapacityProfile)
# ``description`` is always a string. ``None`` and whitespace-only
# inputs are mapped to the empty string.
assert isinstance(profile.description, str)
if description is None or not description.strip():
assert profile.description == ""
# All list-typed fields are real lists (never ``None``).
assert isinstance(profile.competences, list)
assert isinstance(profile.references, list)
assert isinstance(profile.certificates, list)
# Every reference entry is a ``CapacityReferenceEntry``; references
# with an empty ``partner_name`` are kept.
for entry in profile.references:
assert isinstance(entry, CapacityReferenceEntry)
assert isinstance(entry.partner_name, str)
assert isinstance(entry.projects, str)
# The strategy guarantees non-whitespace ``projects``, so the
# builder must keep every input row regardless of partner name.
expected_count = len(references) if references else 0
assert len(profile.references) == expected_count
# The deterministic serialization still contains every heading,
# even when all input fields were empty/None.
serialized = serialize_capacity_profile(profile)
for heading in _CAPACITY_HEADINGS:
assert heading in serialized, (
f"Heading {heading!r} missing from serialization: "
f"{serialized!r}"
)
@settings(max_examples=100, deadline=None)
@given(task=_task)
def test_build_task_profile_handles_empty_or_none_fields(
task: Task,
) -> None:
"""Property 2 (Task): empty/None inputs never discard the profile.
For any :class:`Task` with empty/None-heavy fields,
``build_task_profile`` must return a fully populated
:class:`TaskProfile` whose string fields are real strings (never
``None``) and whose ``skills`` list is a real list. The
deterministic serialization still contains every field heading.
"""
profile = build_task_profile(task)
# Profile is never discarded.
assert isinstance(profile, TaskProfile)
# All string fields are real strings (never ``None``).
assert isinstance(profile.id, str)
assert isinstance(profile.title, str)
assert isinstance(profile.description, str)
# ``skills`` is always a real list.
assert isinstance(profile.skills, list)
for skill in profile.skills:
assert isinstance(skill, str)
# Empty inputs are mirrored as empty strings on the output.
if not task.title:
assert profile.title == ""
if not task.description:
assert profile.description == ""
# The deterministic serialization still contains every heading.
serialized = serialize_task_profile(profile)
for heading in _TASK_HEADINGS:
assert heading in serialized, (
f"Heading {heading!r} missing from serialization: "
f"{serialized!r}"
)
@settings(max_examples=100, deadline=None)
@given(requirements=_requirements)
def test_build_task_profile_from_requirements_handles_empty_or_none_fields(
requirements: Requirements,
) -> None:
"""Property 2 (Requirements): empty/None inputs never discard profile.
For any :class:`Requirements` with empty/None-heavy fields,
``build_task_profile_from_requirements`` must return a fully
populated :class:`TaskProfile` whose string fields are real strings
(never ``None``) and whose ``skills`` list is a real list. The
deterministic serialization still contains every field heading.
"""
profile = build_task_profile_from_requirements(requirements)
# Profile is never discarded.
assert isinstance(profile, TaskProfile)
# All string fields are real strings (never ``None``).
assert isinstance(profile.id, str)
assert isinstance(profile.title, str)
assert isinstance(profile.description, str)
# ``skills`` is always a real list.
assert isinstance(profile.skills, list)
for skill in profile.skills:
assert isinstance(skill, str)
# ``None`` inputs from the requirements are mirrored as empty
# strings/lists on the output.
if requirements.role_name is None:
assert profile.title == ""
if requirements.description is None:
assert profile.description == ""
if not requirements.competences:
assert profile.skills == []
# The deterministic serialization still contains every heading.
serialized = serialize_task_profile(profile)
for heading in _TASK_HEADINGS:
assert heading in serialized, (
f"Heading {heading!r} missing from serialization: "
f"{serialized!r}"
)
@@ -0,0 +1,146 @@
# Feature: llm-fulltext-matching, Property 1: Profil-Serialisierung ist deterministisch und feldvollständig
"""Property-based tests for deterministic profile serialization.
These tests cover the deterministic serialization helpers
``serialize_capacity_profile`` and ``serialize_task_profile`` defined in
``teamlandkarte_mcp.matching.profiles``. They verify three core properties
that hold for every randomly generated profile:
1. **Determinism**: calling the serializer twice on the same profile yields
exactly identical strings.
2. **Field-heading completeness**: the serialized output always contains
every field heading required by the spec.
3. **Partner-name visibility**: when a ``CapacityReferenceEntry`` has a
non-empty ``partner_name``, that name appears as a substring of the
serialized capacity profile.
**Validates: Requirements 3.3, 3.4, 3.6, 3.7, 4.3, 4.4**
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.profiles import (
CapacityProfile,
CapacityReferenceEntry,
TaskProfile,
serialize_capacity_profile,
serialize_task_profile,
)
# Small text strategy to keep generation fast and deterministic.
_text = st.text(max_size=40)
# `partner_name` may be empty (NULL `partner_id` / Join-Mismatch) or any
# short non-empty text.
_partner_name = st.one_of(st.just(""), st.text(min_size=1, max_size=20))
# The builder strips empty `projects`, but the serializer renders every
# entry it receives. Use non-whitespace projects so containment assertions
# are unambiguous.
_projects = st.text(min_size=1, max_size=40).filter(lambda s: s.strip() != "")
_reference = st.builds(
CapacityReferenceEntry,
partner_name=_partner_name,
projects=_projects,
)
_capacity_profile = st.builds(
CapacityProfile,
id=_text,
owner_name=_text,
role_name=_text,
competences=st.lists(_text, max_size=5),
description=_text,
references=st.lists(_reference, max_size=5),
certificates=st.lists(
_text.filter(lambda s: s.strip() != ""), max_size=5
),
)
_task_profile = st.builds(
TaskProfile,
id=_text,
title=_text,
description=_text,
skills=st.lists(_text, max_size=5),
)
_CAPACITY_HEADINGS = (
"Rolle:",
"Kompetenzen:",
"Beschreibung:",
"Referenzen:",
"Zertifikate:",
)
_TASK_HEADINGS = (
"Titel:",
"Beschreibung:",
"Gesuchte Kompetenzen:",
)
@settings(max_examples=100, deadline=None)
@given(profile=_capacity_profile)
def test_capacity_profile_serialization_is_deterministic_and_complete(
profile: CapacityProfile,
) -> None:
"""Property 1 (Capacity): determinism, heading completeness, partner name.
For any :class:`CapacityProfile` (including references with empty or
non-empty ``partner_name``) the serializer must:
* produce identical strings on repeated calls (determinism),
* always include every field heading required by the spec, and
* include every non-empty ``partner_name`` from the references list as
a substring of the serialized output.
"""
first = serialize_capacity_profile(profile)
second = serialize_capacity_profile(profile)
# Determinism: two consecutive calls return the exact same string.
assert first == second, "serialize_capacity_profile is not deterministic"
# Every field heading must always be present.
for heading in _CAPACITY_HEADINGS:
assert heading in first, (
f"Heading {heading!r} missing from capacity serialization: {first!r}"
)
# Each reference with a non-empty partner_name must appear verbatim.
for ref in profile.references:
if ref.partner_name:
assert ref.partner_name in first, (
f"Partner name {ref.partner_name!r} missing from "
f"serialization: {first!r}"
)
@settings(max_examples=100, deadline=None)
@given(profile=_task_profile)
def test_task_profile_serialization_is_deterministic_and_complete(
profile: TaskProfile,
) -> None:
"""Property 1 (Task): determinism and heading completeness.
For any :class:`TaskProfile` the serializer must produce identical
strings on repeated calls and always include every field heading
required by the spec.
"""
first = serialize_task_profile(profile)
second = serialize_task_profile(profile)
# Determinism: two consecutive calls return the exact same string.
assert first == second, "serialize_task_profile is not deterministic"
# Every field heading must always be present.
for heading in _TASK_HEADINGS:
assert heading in first, (
f"Heading {heading!r} missing from task serialization: {first!r}"
)
@@ -0,0 +1,21 @@
from __future__ import annotations
from teamlandkarte_mcp.cache.query_cache import QueryCache
def test_query_cache_hit_miss_behavior() -> None:
cache: QueryCache[int] = QueryCache(ttl_hours=12, max_size=10)
calls = {"n": 0}
def fetch() -> int:
calls["n"] += 1
return 123
assert cache.get_or_fetch("k", fetch) == 123
assert cache.stats["misses"] == 1
assert cache.stats["hits"] == 0
assert calls["n"] == 1
assert cache.get_or_fetch("k", fetch) == 123
assert cache.stats["hits"] == 1
assert calls["n"] == 1
@@ -0,0 +1,13 @@
from __future__ import annotations
from teamlandkarte_mcp.database.query_logger import normalize_sql, redact_params
def test_normalize_sql_collapses_whitespace() -> None:
assert normalize_sql("SELECT\n 1\t\tFROM t") == "SELECT 1 FROM t"
def test_redact_params_redacts_strings() -> None:
assert redact_params(["secret"]) == ("se***et",)
assert redact_params(["a"]) == ("***",)
assert redact_params([123, None, True]) == (123, None, True)
@@ -0,0 +1,23 @@
from __future__ import annotations
import pytest
import teamlandkarte_mcp.database.read_only as ro
def test_ensure_select_only_allows_select() -> None:
ro.ensure_select_only("SELECT 1")
@pytest.mark.parametrize(
"sql",
[
"DELETE FROM t",
"UPDATE t SET x = 1",
"INSERT INTO t VALUES (1)",
"DROP TABLE t",
],
)
def test_ensure_select_only_rejects_writes(sql: str) -> None:
with pytest.raises(ro.DatabaseError):
ro.ensure_select_only(sql)
@@ -0,0 +1,188 @@
# Feature: llm-fulltext-matching, Property 2b:
# Referenzen mit leerem Partner-Name behalten projects, ohne Partner-Token
"""Property-based tests for reference rendering with empty partner names.
These tests cover the deterministic serializer
``serialize_capacity_profile`` defined in
``teamlandkarte_mcp.matching.profiles``. They focus specifically on the
``Referenzen:`` block and verify three invariants for any list of
:class:`CapacityReferenceEntry` values that mixes empty and non-empty
``partner_name`` values:
(a) The number of rendered reference lines equals the number of input
entries with non-whitespace ``projects``.
(b) Each line that corresponds to an entry with empty ``partner_name``
starts with ``Projekte:`` and does NOT contain a ``Partner:`` token.
(c) Each line that corresponds to an entry with non-empty
``partner_name`` contains both ``Partner: <name>`` and
``Projekte: <projects>``.
Because the serializer classifies entries via ``if entry.partner_name:``
(truthiness), this test mirrors that exact logic and uses
``bool(entry.partner_name)`` to decide whether the ``Partner:`` token is
expected.
The strategies deliberately exclude line-break-like characters
(everything ``str.splitlines`` treats as a line boundary) so the
line-based extraction of the rendered ``Referenzen:`` block remains
unambiguous. The property under test is the *content* of the rendered
references, not how the serializer would behave on text containing
literal newlines.
**Validates: Requirements 2.8, 3.4, 3.6**
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.profiles import (
CapacityProfile,
CapacityReferenceEntry,
serialize_capacity_profile,
)
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Alphabet for partner_name/projects: any unicode codepoint that
# ``str.splitlines`` does NOT treat as a line boundary, so the rendered
# output remains a stable, single-line-per-entry block. We exclude:
#
# * ``Cs`` (surrogates) — invalid in well-formed strings.
# * ``Cc`` (control characters) — includes ``\n``, ``\r``, ``\v``,
# ``\f``, ``\x1c``-``\x1f``, ``\x85`` etc.
# * ``Zl`` (line separator, ``\u2028``).
# * ``Zp`` (paragraph separator, ``\u2029``).
_safe_chars = st.characters(
blacklist_categories=("Cs", "Cc", "Zl", "Zp"),
)
# ``partner_name``: either empty (NULL ``partner_id`` or join mismatch,
# see requirement 2.8) or a short non-empty string. The serializer uses
# truthy-checking, so any non-empty string (including whitespace-only)
# counts as "has partner" — we keep this strategy aligned with that
# logic and classify entries by ``bool(entry.partner_name)`` in the
# assertions below.
_partner_name = st.one_of(
st.just(""),
st.text(alphabet=_safe_chars, min_size=1, max_size=20),
)
# ``projects``: non-whitespace text so the serializer never drops the
# entry defensively. This lets us assert the stronger invariant
# "input entry count == rendered line count".
_projects = st.text(
alphabet=_safe_chars, min_size=1, max_size=30
).filter(lambda s: s.strip() != "")
_reference = st.builds(
CapacityReferenceEntry,
partner_name=_partner_name,
projects=_projects,
)
_reference_list = st.lists(_reference, min_size=1, max_size=8)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_reference_lines(serialized: str) -> list[str]:
"""Extract the rendered reference lines from a serialized profile.
The serializer renders the ``Referenzen:`` block as a heading line
followed by either a bullet list (one ``- <line>`` per entry) or
`` (keine)`` on the same line as the heading. The next field
heading is always ``Zertifikate:`` at the start of its own line,
which we use as the terminator. The leading ``- `` prefix is
stripped from each line so callers can assert directly on the
rendered content.
"""
lines: list[str] = []
in_references = False
for raw_line in serialized.splitlines():
if raw_line.startswith("Zertifikate:"):
break
if raw_line.startswith("Referenzen:"):
in_references = True
continue
if in_references and raw_line.startswith("- "):
lines.append(raw_line[2:])
return lines
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(refs=_reference_list)
def test_references_with_empty_partner_name_keep_projects_without_partner_token(
refs: list[CapacityReferenceEntry],
) -> None:
"""Property 2b: empty ``partner_name`` keeps ``projects`` without token.
For any list of :class:`CapacityReferenceEntry` values with mixed
``partner_name``:
(a) The number of rendered reference lines equals the number of
input entries (the strategy guarantees non-whitespace
``projects``, so no entry is dropped).
(b) Every line for an entry with empty ``partner_name`` starts with
``Projekte:`` and contains no ``Partner:`` token.
(c) Every line for an entry with non-empty ``partner_name`` contains
both ``Partner: <name>`` and ``Projekte: <projects>``.
"""
profile = CapacityProfile(
id="x",
owner_name="o",
role_name="r",
competences=[],
description="",
references=list(refs),
certificates=[],
)
serialized = serialize_capacity_profile(profile)
lines = _extract_reference_lines(serialized)
# (a) One rendered line per input entry, in input order.
assert len(lines) == len(refs), (
f"Expected {len(refs)} reference lines, got {len(lines)}: "
f"{lines!r}"
)
# (b) and (c): per-entry assertions, mirroring the serializer's
# ``if entry.partner_name:`` truthiness check.
for entry, line in zip(refs, lines):
projects_clean = entry.projects.strip()
if entry.partner_name:
# (c) Non-empty partner name → both tokens present.
assert f"Partner: {entry.partner_name}" in line, (
f"Expected 'Partner: {entry.partner_name}' in line: "
f"{line!r}"
)
assert f"Projekte: {projects_clean}" in line, (
f"Expected 'Projekte: {projects_clean}' in line: "
f"{line!r}"
)
else:
# (b) Empty partner name → starts with ``Projekte:`` and no
# ``Partner:`` token at all.
assert line.startswith("Projekte:"), (
f"Expected line to start with 'Projekte:', got: {line!r}"
)
assert "Partner:" not in line, (
f"Did not expect 'Partner:' token in line: {line!r}"
)
assert line == f"Projekte: {projects_clean}", (
f"Expected exact 'Projekte: {projects_clean}', got: "
f"{line!r}"
)
+142
View File
@@ -0,0 +1,142 @@
# filepath: tests/test_rrf.py
"""Unit tests for :mod:`teamlandkarte_mcp.matching.rrf`.
Covers edge cases of :func:`reciprocal_rank_fusion`: empty input, all-zero
input, single list, multiple lists, normalization, and the ``k`` parameter.
"""
from __future__ import annotations
import math
import pytest
from teamlandkarte_mcp.matching.rrf import reciprocal_rank_fusion
# ---------------------------------------------------------------------------
# Empty / all-zero inputs
# ---------------------------------------------------------------------------
def test_rrf_empty_input_returns_empty() -> None:
"""No ranked lists → empty result."""
assert reciprocal_rank_fusion([]) == {}
def test_rrf_empty_list_returns_empty() -> None:
"""Single empty ranked list → empty result."""
assert reciprocal_rank_fusion([[]]) == {}
def test_rrf_all_zero_scores_returns_empty() -> None:
"""All-zero BM25 scores → no token overlap signal → empty result."""
result = reciprocal_rank_fusion([[("A", 0.0), ("B", 0.0)]])
assert result == {}
def test_rrf_mixed_zero_and_nonzero_list() -> None:
"""Only the non-zero list contributes; all-zero list is skipped."""
result = reciprocal_rank_fusion(
[
[("A", 3.5), ("B", 1.0)],
[("A", 0.0), ("B", 0.0)],
]
)
# Both A and B should appear (from first list only)
assert "A" in result
assert "B" in result
# ---------------------------------------------------------------------------
# Single list — normalization to [0, 1]
# ---------------------------------------------------------------------------
def test_rrf_single_list_single_candidate_score_one() -> None:
"""Single candidate in single list → score 1.0."""
result = reciprocal_rank_fusion([[("Python", 3.5)]])
assert result == {"Python": 1.0}
def test_rrf_single_list_top_candidate_score_one() -> None:
"""Top-ranked candidate always maps to 1.0 after normalization."""
result = reciprocal_rank_fusion(
[[("A", 5.0), ("B", 2.0), ("C", 0.5)]]
)
assert math.isclose(result["A"], 1.0, rel_tol=1e-9)
def test_rrf_single_list_scores_in_descending_order() -> None:
"""Higher-ranked candidates receive higher normalized scores."""
result = reciprocal_rank_fusion(
[[("A", 5.0), ("B", 2.0), ("C", 0.5)]]
)
assert result["A"] > result["B"] > result["C"]
def test_rrf_single_list_all_scores_in_unit_interval() -> None:
"""All scores are in [0.0, 1.0]."""
result = reciprocal_rank_fusion(
[[("A", 5.0), ("B", 2.0), ("C", 0.5)]]
)
for score in result.values():
assert 0.0 <= score <= 1.0
def test_rrf_zero_score_entry_not_in_result() -> None:
"""Candidate with BM25 score 0.0 receives no RRF credit (zero-out rule)."""
result = reciprocal_rank_fusion(
[[("A", 3.5), ("B", 0.0)]]
)
assert "A" in result
assert "B" not in result
# ---------------------------------------------------------------------------
# k parameter
# ---------------------------------------------------------------------------
def test_rrf_default_k_rank2_score() -> None:
"""With default k=60, rank-2 score normalized = 61/62 ≈ 0.9839."""
result = reciprocal_rank_fusion(
[[("A", 2.0), ("B", 1.0)]]
)
# raw(A) = 1/61, raw(B) = 1/62
# normalized(B) = (1/62) / (1/61) = 61/62
expected_b = 61.0 / 62.0
assert math.isclose(result["B"], expected_b, rel_tol=1e-9)
def test_rrf_higher_k_flattens_differences() -> None:
"""Higher k → smaller gap between rank-1 and rank-2 scores."""
result_low_k = reciprocal_rank_fusion(
[[("A", 2.0), ("B", 1.0)]], k=1
)
result_high_k = reciprocal_rank_fusion(
[[("A", 2.0), ("B", 1.0)]], k=1000
)
gap_low = result_low_k["A"] - result_low_k["B"]
gap_high = result_high_k["A"] - result_high_k["B"]
assert gap_low > gap_high
# ---------------------------------------------------------------------------
# Multiple lists (fusion)
# ---------------------------------------------------------------------------
def test_rrf_two_lists_fuse_correctly() -> None:
"""Candidate appearing in both lists accumulates RRF scores."""
result = reciprocal_rank_fusion(
[
[("A", 3.0), ("B", 1.0)],
[("B", 2.5), ("A", 0.5)],
]
)
# A gets credit from list-1 (rank 1) and list-2 (rank 2).
# B gets credit from list-1 (rank 2) and list-2 (rank 1).
assert "A" in result
assert "B" in result
# Both should be < 1.0 unless one dominates; top candidate maps to 1.0.
assert max(result.values()) == pytest.approx(1.0)
@@ -0,0 +1,25 @@
from __future__ import annotations
from teamlandkarte_mcp.database.schema_verifier import verify_required_columns
class StubDB:
def __init__(self, mapping: dict[str, list[str]]):
self._mapping = mapping
def get_table_columns(self, table: str) -> list[str]:
return self._mapping.get(table, [])
def test_verify_required_columns_ok() -> None:
db = StubDB({"t": ["id", "name"]})
issues = verify_required_columns(db=db, expected={"t": {"id", "name"}})
assert issues == []
def test_verify_required_columns_missing() -> None:
db = StubDB({"t": ["id"]})
issues = verify_required_columns(db=db, expected={"t": {"id", "name"}})
assert len(issues) == 1
assert issues[0].table == "t"
assert "missing columns" in issues[0].message
@@ -0,0 +1,198 @@
# Feature: llm-fulltext-matching, Property 10: Score-Modus ist abwärtskompatibel
"""Property test verifying that the score mode is fully backward compatible.
When ``find_matching_capacities`` is invoked without ``matching_method``
(default), or explicitly with ``matching_method="score"``, the rendered
result table must keep the existing 9-column layout including the score
columns ``Role Score``, ``Competence Score`` and ``Overall Score``, and
must NOT include a ``Begründung`` column. The persisted payload schema
maps directly to those columns, so the table contract stands in for the
payload schema check (and the table headers ARE the visible part of the
schema for downstream consumers).
The two distinct invocation styles (``method=None`` and
``method="score"``) are enumerated by ``pytest.mark.parametrize``; the
header keeps the "Property 10" tag for spec traceability.
**Validates: Requirements 1.2, 1.3, 7.3**
"""
from __future__ import annotations
import json
from datetime import date
from typing import Any
from unittest.mock import AsyncMock
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Capacity
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
class _FakeDB:
"""Minimal fake DB returning a single matching capacity."""
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return [
Capacity(
id=1,
owner_name="A",
role_name="X",
role_level=None,
begin_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
competences=["A"],
)
]
def get_recent_free_capacities(self, limit: int = 20):
return []
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META=") :])
raise AssertionError(f"no META= line in tool output:\n{output}")
@pytest.fixture
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
@pytest.mark.asyncio
@pytest.mark.parametrize("method_arg", [None, "score"])
async def test_score_mode_payload_is_backward_compatible(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
_env,
method_arg: str | None,
) -> None:
"""Property 10: score-mode invocations preserve the legacy schema.
Both the implicit default (``matching_method`` omitted) and the
explicit ``"score"`` value must yield the legacy table headers and
a META payload tagged with ``"score"``.
"""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
)
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
AsyncMock(return_value='{"similarity": 1.0}'),
)
srv = build_server(str(cfg))
args: dict[str, Any] = {"role_name": "X", "competences": ["A"]}
if method_arg is not None:
args["matching_method"] = method_arg
res = await _call_tool(srv, "find_matching_capacities", args)
# Legacy 9-column header MUST be present.
assert "| Role Score |" in res, (
"score mode must keep the Role Score column"
)
assert "| Competence Score |" in res, (
"score mode must keep the Competence Score column"
)
assert "| Overall Score |" in res, (
"score mode must keep the Overall Score column"
)
# Begründung column belongs exclusively to the LLM mode.
assert "Begründung" not in res, (
"score mode must not render the Begründung column"
)
# META JSON must tag the search as score mode.
meta = _extract_meta(res)
assert meta.get("matching_method") == "score", (
f"expected matching_method='score', got META={meta!r}"
)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from teamlandkarte_mcp.config import MatchingConfig, MatchingThresholds
from teamlandkarte_mcp.matching.scorer import categorize, compute_overall
def test_compute_overall_round6_weights() -> None:
cfg = MatchingConfig(
competence_weight=0.8,
role_weight=0.2,
thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4),
)
b = compute_overall(competence_score=1.0, role_score=0.0, cfg=cfg)
assert b.overall_score == 0.8
assert b.category == "Top"
b = compute_overall(competence_score=0.0, role_score=1.0, cfg=cfg)
assert b.overall_score == 0.2
assert b.category == "Low"
def test_categorize_threshold_boundaries() -> None:
"""Test categorization with recommended thresholds (Feb 2026)."""
cfg = MatchingConfig(
competence_weight=0.8,
role_weight=0.2,
thresholds=MatchingThresholds(
top=0.8,
good=0.65,
partial=0.5,
low=0.3,
),
)
# Top boundary
assert categorize(0.8, cfg) == "Top"
assert categorize(0.79, cfg) == "Good"
# Good boundary
assert categorize(0.65, cfg) == "Good"
assert categorize(0.64, cfg) == "Partial"
# Partial boundary
assert categorize(0.5, cfg) == "Partial"
assert categorize(0.49, cfg) == "Low"
# Low boundary
assert categorize(0.3, cfg) == "Low"
assert categorize(0.29, cfg) == "Irrelevant"
def test_categorize_with_low_threshold_config() -> None:
"""Test categorization with low=0.2 threshold (user's actual config)."""
cfg = MatchingConfig(
competence_weight=1.0,
role_weight=0.0,
thresholds=MatchingThresholds(
top=0.8,
good=0.6,
partial=0.4,
low=0.2, # User's actual config
),
)
# Exact boundaries
assert categorize(0.8, cfg) == "Top"
assert categorize(0.6, cfg) == "Good"
assert categorize(0.4, cfg) == "Partial" # ← Should be Partial!
assert categorize(0.2, cfg) == "Low"
assert categorize(0.19, cfg) == "Irrelevant"
# Scores slightly above 0.4 should be Partial
assert categorize(0.41, cfg) == "Partial"
assert categorize(0.42, cfg) == "Partial"
assert categorize(0.45, cfg) == "Partial"
assert categorize(0.5, cfg) == "Partial"
# Scores between 0.2 and 0.4 should be Low
assert categorize(0.21, cfg) == "Low"
assert categorize(0.3, cfg) == "Low"
assert categorize(0.39, cfg) == "Low"
# Scores below 0.2 should be Irrelevant
assert categorize(0.15, cfg) == "Irrelevant"
assert categorize(0.1, cfg) == "Irrelevant"
def test_compute_overall_with_user_config() -> None:
"""Test overall score computation with user's actual config (competence_weight=1.0)."""
cfg = MatchingConfig(
competence_weight=1.0,
role_weight=0.0,
thresholds=MatchingThresholds(
top=0.8,
good=0.6,
partial=0.4,
low=0.2,
),
)
# When role_weight=0, overall score should equal competence score
b = compute_overall(competence_score=0.45, role_score=0.0, cfg=cfg)
assert b.overall_score == 0.45
assert b.category == "Partial", f"Score 0.45 should be Partial, got {b.category}"
b = compute_overall(competence_score=0.4, role_score=0.0, cfg=cfg)
assert b.overall_score == 0.4
assert b.category == "Partial", f"Score 0.4 should be Partial, got {b.category}"
b = compute_overall(competence_score=0.39, role_score=0.5, cfg=cfg)
assert b.overall_score == 0.39 # role is ignored (weight=0)
assert b.category == "Low", f"Score 0.39 should be Low, got {b.category}"
@@ -0,0 +1,193 @@
from __future__ import annotations
from teamlandkarte_mcp.cache.search_cache import SearchCache
def test_filter_id_increments_under_same_search_id() -> None:
cache = SearchCache(ttl_minutes=60, max_size=10)
search_id = cache.store_search(
task_id=None, requirements={}, results={"by_category": {}}
)
filter_id_1 = cache.add_filter(
search_id, filtered_results={"by_category": {}}, filter_meta={}
)
filter_id_2 = cache.add_filter(
search_id, filtered_results={"by_category": {}}, filter_meta={}
)
assert filter_id_1 == "filter-1"
assert filter_id_2 == "filter-2"
entry = cache.get(search_id)
assert entry is not None
assert filter_id_1 in entry.filters
assert filter_id_2 in entry.filters
# ---------------------------------------------------------------------------
# Team-search coverage (Profile_Type="team")
# ---------------------------------------------------------------------------
#
# The SearchCache itself is search-type-agnostic - it stores whatever
# ``results`` payload it receives. The tests below exercise the same
# cache primitives (``store_search``, ``add_filter``, ``get``) for
# ``team_search`` payloads in both ``matching_method`` flavours
# (``score`` and ``llm_fulltext``) so the entire cache surface is
# covered for both ``Profile_Type`` values.
#
# _Requirements: 8.1, 8.2, 8.3, 12.7_
def _team_payload(matching_method: str) -> dict:
"""Return a minimal team_search payload mimicking the shapes that
``find_matching_teams`` persists in either ``matching_method`` mode.
"""
base_team = {
"team_id": "t1",
"ouid": "ou-1",
"team_name": "Team Alpha",
"focus_name": "Backend Developer",
"about_us": "",
"offerings": "",
"interests": "",
"competences": [{"name": "python", "top_competency": True}],
"references": [],
"category": "Top",
}
if matching_method == "score":
scored_team = {
**base_team,
"competence_score": 1.0,
"role_score": 1.0,
"overall_score": 1.0,
}
return {
"search_type": "team_search",
"matching_method": "score",
"reference": {
"availability_date_start": None,
"availability_date_end": None,
},
"summary": {"Top": 1, "Good": 0, "Partial": 0, "Low": 0,
"Irrelevant": 0},
"by_category": {
"Top": [scored_team],
"Good": [],
"Partial": [],
"Low": [],
"Irrelevant": [],
},
}
# llm_fulltext
llm_team = {**base_team, "rationale": "Backend match."}
return {
"search_type": "team_search",
"matching_method": "llm_fulltext",
"reference": {
"availability_date_start": None,
"availability_date_end": None,
},
"summary": {"Top": 1, "Good": 0, "Partial": 0, "Low": 0,
"Irrelevant": 0},
"by_category": {
"Top": [llm_team],
"Good": [],
"Partial": [],
"Low": [],
"Irrelevant": [],
},
"errors": [],
}
def test_team_search_cache_roundtrip_score_mode() -> None:
"""``team_search`` payloads round-trip through the cache and keep
their ``search_type``/``matching_method`` markers (Anforderungen
8.1, 8.2, 8.3)."""
cache = SearchCache(ttl_minutes=60, max_size=10)
payload = _team_payload("score")
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "Backend Developer",
"competences": ["python"]},
results=payload,
)
entry = cache.get(search_id)
assert entry is not None
assert entry.results["search_type"] == "team_search"
assert entry.results["matching_method"] == "score"
# The cached items keep the team-specific fields used by the
# team-search column layout (Anforderung 8.4).
top = entry.results["by_category"]["Top"]
assert top and top[0]["team_id"] == "t1"
assert "competence_score" in top[0]
assert "role_score" in top[0]
assert "overall_score" in top[0]
def test_team_search_cache_roundtrip_llm_fulltext_mode() -> None:
"""``team_search`` LLM-mode payloads survive a cache round trip
including the ``rationale`` field used by ``Begründung``."""
cache = SearchCache(ttl_minutes=60, max_size=10)
payload = _team_payload("llm_fulltext")
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "Backend Developer",
"competences": ["python"]},
results=payload,
)
entry = cache.get(search_id)
assert entry is not None
assert entry.results["search_type"] == "team_search"
assert entry.results["matching_method"] == "llm_fulltext"
# ``errors`` list is preserved for the LLM-fulltext path
# (Anforderung 7.6).
assert entry.results.get("errors") == []
top = entry.results["by_category"]["Top"]
assert top and top[0]["team_id"] == "t1"
assert top[0]["category"] == "Top"
assert top[0]["rationale"] == "Backend match."
def test_team_search_filter_ids_increment_independently() -> None:
"""``add_filter`` produces incrementing ids for ``team_search``
entries just like for capacity searches.
"""
cache = SearchCache(ttl_minutes=60, max_size=10)
search_id = cache.store_search(
task_id=None,
requirements={"role_name": "Backend Developer",
"competences": ["python"]},
results=_team_payload("score"),
)
fid1 = cache.add_filter(
search_id,
filtered_results={"by_category": {}, "search_type": "team_search",
"matching_method": "score"},
filter_meta={"role_filter": "Backend"},
)
fid2 = cache.add_filter(
search_id,
filtered_results={"by_category": {}, "search_type": "team_search",
"matching_method": "score"},
filter_meta={"role_filter": "Frontend"},
)
assert fid1 == "filter-1"
assert fid2 == "filter-2"
entry = cache.get(search_id)
assert entry is not None
# The filter payloads keep the team_search marker independent of
# the parent entry, so downstream consumers can distinguish them.
for fid in (fid1, fid2):
fdata = entry.filters[fid]
assert fdata["results"]["search_type"] == "team_search"
assert fdata["results"]["matching_method"] == "score"
@@ -0,0 +1,44 @@
from __future__ import annotations
import pytest
from teamlandkarte_mcp.cache.search_cache import SearchCache
def test_cache_diagnostics_has_instance_id_and_ttl() -> None:
cache = SearchCache(ttl_minutes=1, max_size=2, enable_diagnostics=True)
diag = cache.diagnostics()
assert isinstance(diag["instance_id"], str)
assert diag["ttl_seconds"] == 60
assert diag["max_size"] == 2
assert diag["size"] == 0
def test_ttl_like_miss_leads_to_none() -> None:
cache = SearchCache(ttl_minutes=1, max_size=10, enable_diagnostics=True)
search_id = cache.store_search(
task_id=None, requirements={}, results={"by_category": {}}
)
# Real-time TTL expiry would require sleeping > 60s. Instead, we assert
# miss semantics by removing the entry and verifying get() returns None.
cache._cache.pop(search_id, None) # type: ignore[attr-defined]
assert cache.get(search_id) is None
def test_max_size_eviction_can_remove_search_id() -> None:
cache = SearchCache(ttl_minutes=60, max_size=1, enable_diagnostics=True)
first = cache.store_search(
task_id=None, requirements={}, results={"by_category": {}}
)
_ = cache.store_search(task_id=None, requirements={}, results={"by_category": {}})
assert cache.get(first) is None
def test_add_filter_unknown_search_id_raises_keyerror() -> None:
cache = SearchCache(ttl_minutes=60, max_size=10, enable_diagnostics=True)
with pytest.raises(KeyError):
cache.add_filter("does-not-exist", filtered_results={}, filter_meta={})
diag = cache.diagnostics()
assert diag["misses"] >= 0
@@ -0,0 +1,134 @@
# Feature: llm-fulltext-matching, Property 8: Ungekürzte Rationale wird persistiert
"""Property-based test verifying that long rationales are persisted verbatim.
The MCP server stores LLM matcher results into ``SearchCache`` by merging
the source ``Capacity``/``Task`` payload with ``category`` and
``rationale`` fields from each :class:`LlmFulltextItem` (see
``mcp_server.find_matching_capacities``). The persistence step is a
simple pass-through:
merged = dict(it.raw)
merged["rationale"] = it.rationale
That means the LLM-returned rationale on the
:class:`~teamlandkarte_mcp.matching.llm_fulltext_matcher.LlmFulltextItem`
must already be byte-for-byte identical to the LLM payload. This test
exercises the matcher directly with a fake LLM that echoes long
rationales (>= 300 chars) and asserts the resulting item carries the
same string back.
Testing at the matcher level is sufficient evidence for requirement 8.6:
the cache pass-through cannot introduce truncation if the source item
already holds the unmodified text. The table-rendering helper
``_format_rationale_for_table`` is covered separately by the Property 7
test (``test_format_rationale_pbt.py``).
**Validates: Requirements 8.6**
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
# Exclude characters that would be mangled by JSON serialization or that
# Hypothesis would otherwise generate (lone surrogates, control chars,
# line/paragraph separators). The remaining alphabet is wide enough to
# exercise multi-byte text while staying round-trippable through
# ``json.dumps``/``json.loads``.
_RATIONALE_STRATEGY = st.text(
alphabet=st.characters(
blacklist_categories=("Cs", "Cc", "Zl", "Zp"),
),
min_size=300,
max_size=400,
)
class _StubDb:
"""Minimal ``DBClient`` double returning empty extras for every id."""
def batch_get_capacity_descriptions(
self, capacity_ids: list[Any]
) -> dict[str, str | None]:
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(
self, capacity_ids: list[Any]
) -> dict[str, list[str]]:
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(
self, capacity_ids: list[Any]
) -> dict[str, list[dict]]:
return {str(i): [] for i in capacity_ids}
class _EchoLlm:
"""Returns the supplied rationale wrapped in a ``Top`` JSON payload."""
def __init__(self, rationale: str) -> None:
self._rationale = rationale
async def chat_completion(self, system: str, user: str) -> str:
return json.dumps(
{"category": "Top", "rationale": self._rationale},
ensure_ascii=False,
)
def _capacity() -> Capacity:
return Capacity(
id=1,
owner_name="o",
role_name="R",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
@settings(max_examples=100, deadline=None)
@given(rationale=_RATIONALE_STRATEGY)
def test_long_rationale_is_persisted_unchanged(rationale: str) -> None:
"""Property 8: matcher returns the rationale verbatim, length >= 300.
The matcher's per-item ``rationale`` is what the MCP server merges
into the persisted SearchCache payload, so equality at this layer
proves the rationale survives the pass-through unchanged.
"""
matcher = LlmFulltextMatcher(
db=_StubDb(),
client=_EchoLlm(rationale),
)
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
result = asyncio.run(
matcher.match_capacities(
task_profile=task_profile,
capacities=[_capacity()],
)
)
assert result.errors == [], f"unexpected errors: {result.errors}"
items = result.by_category["Top"]
assert len(items) == 1, "expected exactly one Top item"
item = items[0]
assert item.category == "Top"
# The rationale must be bit-identical to what the LLM returned, even
# though the input is well above the 280-char display limit.
assert item.rationale == rationale, (
"rationale was modified between LLM response and matcher item"
)
assert len(item.rationale) >= 300
@@ -0,0 +1,337 @@
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from typing import Any, cast
from teamlandkarte_mcp.matching.similarity import (
SimilarityEngine,
)
from teamlandkarte_mcp.matching.bm25 import Bm25Index
class _DummyClient: # pragma: no cover
"""Minimal stub for AzureOpenAIClient."""
async def chat_completion(self, system: str, user: str) -> str:
return json.dumps({"similarity": 0.5})
# ---------------------------------------------------------------------------
# BM25 + RRF mode (now the only competence similarity path)
# ---------------------------------------------------------------------------
def _bm25_engine(**kwargs: Any) -> SimilarityEngine:
"""Create a SimilarityEngine (BM25 is now always active)."""
return SimilarityEngine(
client=_DummyClient(), # type: ignore[arg-type]
**kwargs,
)
@pytest.mark.asyncio
async def test_bm25_exact_match_scores_one() -> None:
"""Required 'Python', candidate ['Python'] → score 1.0 with global index.
A global index over five distinct skills ensures 'python' has a positive
IDF, so the exact-match document receives a positive BM25 score and RRF
normalizes the single-result list to 1.0.
"""
engine = _bm25_engine()
global_index = Bm25Index(corpus=["Python", "JavaScript", "Java", "Go", "Ruby"])
result = await engine.compute_competence_similarity(
required=["Python"],
candidate=["Python"],
global_index=global_index,
)
assert result["Python"]["score"] == pytest.approx(1.0)
assert result["Python"]["best_match"] == "Python"
@pytest.mark.asyncio
async def test_bm25_no_overlap_scores_zero() -> None:
"""Required 'Python', candidate ['JavaScript'] → score 0.0."""
engine = _bm25_engine()
result = await engine.compute_competence_similarity(
required=["Python"],
candidate=["JavaScript"],
)
assert result["Python"]["score"] == 0.0
assert result["Python"]["best_match"] is None
assert "no token overlap" in str(result["Python"]["rationale"]).lower()
@pytest.mark.asyncio
async def test_bm25_multiple_required_no_overlap_all_zero() -> None:
"""Required ['Python', 'ML'], candidate JS-only → both scores 0.0."""
engine = _bm25_engine()
result = await engine.compute_competence_similarity(
required=["Python", "Machine Learning"],
candidate=["JavaScript", "TypeScript"],
)
assert result["Python"]["score"] == 0.0
assert result["Machine Learning"]["score"] == 0.0
@pytest.mark.asyncio
async def test_bm25_multiple_required_all_match() -> None:
"""Required ['Python', 'ML'], candidate exact match → both scores 1.0.
A global index over five distinct skills ensures all query tokens have
positive IDF, so both exact-match documents score 1.0 after RRF.
"""
engine = _bm25_engine()
global_index = Bm25Index(
corpus=["Python", "Machine Learning", "JavaScript", "TypeScript", "Java"]
)
result = await engine.compute_competence_similarity(
required=["Python", "Machine Learning"],
candidate=["Python", "Machine Learning"],
global_index=global_index,
)
assert result["Python"]["score"] == pytest.approx(1.0)
assert result["Machine Learning"]["score"] == pytest.approx(1.0)
@pytest.mark.asyncio
async def test_bm25_no_candidates_returns_zero() -> None:
"""Empty candidate list → score 0.0 for all required."""
engine = _bm25_engine()
result = await engine.compute_competence_similarity(
required=["Python"],
candidate=[],
)
assert result["Python"]["score"] == 0.0
assert result["Python"]["best_match"] is None
@pytest.mark.asyncio
async def test_bm25_rationale_contains_bm25_rrf_on_match() -> None:
"""Rationale mentions 'BM25+RRF' when a match is found.
Uses a global index so the matched document receives a positive BM25
score and RRF fusion produces a non-zero result with the expected
rationale prefix.
"""
engine = _bm25_engine()
global_index = Bm25Index(
corpus=["Python", "Java", "JavaScript", "Go", "Ruby"]
)
result = await engine.compute_competence_similarity(
required=["Python"],
candidate=["Python", "Java"],
global_index=global_index,
)
assert "BM25+RRF" in str(result["Python"]["rationale"])
# ---------------------------------------------------------------------------
# BM25 + auto-tagging (mocked AutoTagger)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_bm25_auto_tagging_expands_candidate_list() -> None:
"""AutoTagger injects 'Machine Learning' into ['ML'] → score > 0.
A global index is provided that includes 'Machine Learning' (simulating
it existing in another candidate's original skill list in the pool).
After expansion the working list contains 'Machine Learning', which is
also in the global corpus, so BM25 can assign it a positive IDF score.
"""
mock_tagger = MagicMock()
mock_tagger.expand_competences = AsyncMock(
return_value=["ML", "Machine Learning"]
)
engine = _bm25_engine(use_auto_tagging=True, auto_tagger=mock_tagger)
global_index = Bm25Index(
corpus=["ML", "Machine Learning", "Python", "JavaScript", "Java"]
)
result = await engine.compute_competence_similarity(
required=["Machine Learning"],
candidate=["ML"],
global_index=global_index,
)
assert float(cast(Any, result["Machine Learning"]["score"])) > 0.0
mock_tagger.expand_competences.assert_called_once_with(
["Machine Learning"], ["ML"]
)
@pytest.mark.asyncio
async def test_bm25_auto_tagging_disabled_uses_raw_candidate() -> None:
"""When use_auto_tagging=False, AutoTagger is never called."""
mock_tagger = MagicMock()
mock_tagger.expand_competences = AsyncMock()
engine = _bm25_engine(use_auto_tagging=False, auto_tagger=mock_tagger)
await engine.compute_competence_similarity(
required=["Machine Learning"],
candidate=["ML"],
)
mock_tagger.expand_competences.assert_not_called()
@pytest.mark.asyncio
async def test_bm25_global_index_filters_to_candidate_subset() -> None:
"""Global index is queried and results filtered to the candidate's own skills.
A global corpus of seven skills is provided; the candidate only knows
'Python'. The engine must return a positive score for the 'Python'
required competence because the global index assigns it positive IDF,
and all other global corpus items are excluded by the filter.
"""
engine = _bm25_engine()
global_index = Bm25Index(
corpus=["Python", "JavaScript", "Java", "Go", "Ruby", "Rust", "TypeScript"]
)
result = await engine.compute_competence_similarity(
required=["Python"],
candidate=["Python"],
global_index=global_index,
)
assert result["Python"]["score"] == pytest.approx(1.0)
assert result["Python"]["best_match"] == "Python"
# ---------------------------------------------------------------------------
# LLM-based role similarity
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_role_similarity_bad_roles_return_zero() -> None:
"""None, empty, and '(unknown)' roles return 0.0 without LLM call."""
engine = _bm25_engine()
assert await engine.compute_role_similarity(None, "Developer") == 0.0
assert await engine.compute_role_similarity("Developer", None) == 0.0
assert await engine.compute_role_similarity("", "Developer") == 0.0
assert await engine.compute_role_similarity("Developer", "") == 0.0
assert await engine.compute_role_similarity(" ", "Developer") == 0.0
assert await engine.compute_role_similarity("(unknown)", "Developer") == 0.0
assert await engine.compute_role_similarity("(Unknown)", "Developer") == 0.0
@pytest.mark.asyncio
async def test_role_similarity_identical_roles_return_one() -> None:
"""Identical roles (case-insensitive) return 1.0 without LLM call."""
engine = _bm25_engine()
assert await engine.compute_role_similarity("Developer", "Developer") == 1.0
assert await engine.compute_role_similarity("Developer", "developer") == 1.0
assert await engine.compute_role_similarity("BACKEND DEV", "backend dev") == 1.0
@pytest.mark.asyncio
async def test_role_similarity_calls_llm_and_parses_response() -> None:
"""LLM returns a similarity score that is parsed and returned."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(
return_value=json.dumps({"similarity": 0.75})
)
engine = SimilarityEngine(client=mock_client)
score = await engine.compute_role_similarity("Backend Developer", "Software Engineer")
assert score == pytest.approx(0.75)
mock_client.chat_completion.assert_called_once()
@pytest.mark.asyncio
async def test_role_similarity_caches_result_symmetrically() -> None:
"""Second call with swapped roles uses cache, no second LLM call."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(
return_value=json.dumps({"similarity": 0.8})
)
engine = SimilarityEngine(client=mock_client)
score1 = await engine.compute_role_similarity("Role A", "Role B")
score2 = await engine.compute_role_similarity("Role B", "Role A")
assert score1 == 0.8
assert score2 == 0.8
# Only one LLM call should have been made
assert mock_client.chat_completion.call_count == 1
@pytest.mark.asyncio
async def test_role_similarity_clamps_score() -> None:
"""Scores outside [0, 1] are clamped."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(
return_value=json.dumps({"similarity": 1.5})
)
engine = SimilarityEngine(client=mock_client)
score = await engine.compute_role_similarity("A", "B")
assert score == 1.0
# Reset for negative test
mock_client.chat_completion = AsyncMock(
return_value=json.dumps({"similarity": -0.3})
)
engine2 = SimilarityEngine(client=mock_client)
score2 = await engine2.compute_role_similarity("C", "D")
assert score2 == 0.0
@pytest.mark.asyncio
async def test_role_similarity_exception_returns_zero() -> None:
"""On any exception, return 0.0 and do not cache."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(side_effect=RuntimeError("API down"))
engine = SimilarityEngine(client=mock_client)
score = await engine.compute_role_similarity("A", "B")
assert score == 0.0
# Cache should be empty (failed results not cached)
assert len(engine._role_similarity_cache) == 0
@pytest.mark.asyncio
async def test_role_similarity_invalid_json_returns_zero() -> None:
"""Invalid JSON from LLM returns 0.0."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(return_value="not json at all")
engine = SimilarityEngine(client=mock_client)
score = await engine.compute_role_similarity("A", "B")
assert score == 0.0
@pytest.mark.asyncio
async def test_role_similarity_missing_key_returns_zero() -> None:
"""JSON without 'similarity' key returns 0.0."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(
return_value=json.dumps({"score": 0.9}) # wrong key
)
engine = SimilarityEngine(client=mock_client)
score = await engine.compute_role_similarity("A", "B")
assert score == 0.0
# ---------------------------------------------------------------------------
# clear_role_cache
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_clear_role_cache() -> None:
"""clear_role_cache empties the cache."""
mock_client = AsyncMock()
mock_client.chat_completion = AsyncMock(
return_value=json.dumps({"similarity": 0.6})
)
engine = SimilarityEngine(client=mock_client)
await engine.compute_role_similarity("A", "B")
assert len(engine._role_similarity_cache) == 1
engine.clear_role_cache()
assert len(engine._role_similarity_cache) == 0
@@ -0,0 +1,12 @@
"""TaskAnalyzer tests removed.
The chat-based TaskAnalyzer was retired in Phase 7 (Azure chat removal).
Requirement extraction now uses structured input collection.
"""
import pytest
@pytest.mark.skip(reason="TaskAnalyzer retired (Azure chat removed)")
def test_task_analyzer_retired() -> None:
assert True
@@ -0,0 +1,12 @@
"""Tests for deprecated task helpers.
The legacy helper `extract_requirements_from_task` was removed in Phase 7 when
Azure chat support was retired.
"""
import pytest
@pytest.mark.skip(reason="task_helpers.extract_requirements_from_task deprecated")
def test_task_helpers_deprecated() -> None:
assert True
@@ -0,0 +1,317 @@
from __future__ import annotations
from typing import Any
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"),
str,
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(
srv: Any,
name: str,
args: dict[str, Any] | None = None,
) -> str:
if args is None:
args = {}
return _result_to_text(await srv.call_tool(name, args))
@pytest.mark.asyncio
async def test_task_search_filters_apply_only_to_task_search(
monkeypatch,
tmp_path,
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
""".strip(),
encoding="utf-8",
)
from dataclasses import dataclass
from datetime import date, datetime
@dataclass
class _Task:
id: str
name: str | None = None
title: str = ""
description: str = ""
created_date: datetime = datetime(2025, 1, 1)
start_date: date | None = None
end_date: date | None = None
skills: list[str] = None # type: ignore[assignment]
def __post_init__(self):
if self.skills is None:
self.skills = []
class _FakeDB:
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Frontend Developer", "Backend Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["React", "TypeScript", "Python", "PostgreSQL"]
def get_open_tasks(self, limit: int = 20):
tasks = [
_Task(
id="T1",
title="Frontend Developer",
description="React and TypeScript",
created_date=datetime(2025, 1, 1),
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
skills=["React", "TypeScript"],
),
_Task(
id="T2",
title="Backend Engineer",
description="Python and PostgreSQL",
created_date=datetime(2025, 1, 1),
start_date=date(2025, 5, 1),
end_date=date(2025, 7, 31),
skills=["Python"],
),
]
return tasks if limit == 0 else tasks[:limit]
def get_task_by_id(self, task_id: str): # pragma: no cover
return None
def get_task_by_name(self, name: str): # pragma: no cover
return None
def get_all_capacities_with_competences(self): # pragma: no cover
return []
def get_recent_free_capacities(
self,
limit: int = 20,
): # pragma: no cover
return []
def get_capacity_by_id(self, capacity_id):
from teamlandkarte_mcp.models import Capacity
return Capacity(
id=1,
owner_name="A",
role_name="Frontend Developer",
role_level=None,
begin_date=date(2025, 4, 1),
end_date=date(2025, 8, 31),
competences=["React", "TypeScript"],
)
monkeypatch.setattr(
mcp_mod,
"create_db_client",
lambda *_args, **_kwargs: _FakeDB(),
)
srv = build_server(str(cfg))
# Create a task search.
res = await _call_tool(srv, "find_matching_tasks", {"capacity_id": 1})
assert "SEARCH_ID=" in res
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
out = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "task_text_filter": "typescript"},
)
assert "Filtered total results: 1" in out
assert "T1" in out
assert "T2" not in out
out2 = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "task_competence_filter": ["python"]},
)
assert "Filtered total results: 1" in out2
assert "T2" in out2
@pytest.mark.asyncio
async def test_is_fully_available_uses_stored_reference_dates(
monkeypatch,
tmp_path,
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
""".strip(),
encoding="utf-8",
)
from datetime import date
from teamlandkarte_mcp.models import Capacity
class _FakeDB:
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return [
Capacity(
id=1,
owner_name="A",
role_name="X",
role_level=None,
begin_date=date(2025, 2, 1),
end_date=date(2025, 7, 31),
competences=[],
),
Capacity(
id=2,
owner_name="B",
role_name="X",
role_level=None,
begin_date=date(2025, 3, 15),
end_date=date(2025, 6, 15),
competences=[],
),
]
def get_recent_free_capacities(self, limit: int = 20):
return []
monkeypatch.setattr(
mcp_mod,
"create_db_client",
lambda *_args, **_kwargs: _FakeDB(),
)
srv = build_server(str(cfg))
res = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "X",
"competences": ["A"],
"date_start": "2025-03-01",
"date_end": "2025-06-30",
},
)
assert "SEARCH_ID=" in res
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
out = await _call_tool(
srv,
"filter_search_results",
{"search_id": search_id, "is_fully_available": True},
)
assert "Filtered total results: 1" in out
assert "| 1 |" in out
assert "| 2 |" not in out
@@ -0,0 +1,369 @@
# Feature: team-profile-matching, Property 4: Kompetenz-Batch ist konsistent zur Einzel-Variante
"""Property-based test for the batch/single consistency of the team
competence queries on ``TrinoClient``.
Validates Property 4 from the team-profile-matching design:
*Für jede* Liste von OUIDs ``ouids`` und jede Stub-DB-Antwort gilt:
``batch_get_team_competences(ouids)[ouid]`` ist für jedes ``ouid`` in
``ouids`` gleich ``get_team_competences(ouid)``. Außerdem gilt für jeden
Eintrag der Ergebnislisten:
- ``top_competency`` ist ``False``, wenn die Quellzeile ``NULL`` enthielt
(Anforderung 3.5),
- Einträge ohne auflösbaren Kompetenz-Namen sind nicht enthalten
(Anforderung 3.6),
- die Reihenfolge ist ``(top_competency desc, name asc)`` und damit
deterministisch (Anforderung 3.7),
- für ``ouid``-Werte ohne Kompetenzen ist die Liste leer
(Anforderung 3.4).
The test installs a stub cursor that simulates the SQL ``ORDER BY`` and
``COALESCE`` semantics for both the single (``WHERE tc.ouid = ?``) and
the batch (``WHERE tc.ouid IN (?, ?, ...)``) form. The simulated cursor
holds an in-memory list of competence rows
``(ouid, name, top_competency)`` where ``name`` may be ``None`` or empty
(to exercise the Python-side filter, Requirement 3.6) and
``top_competency`` may be ``None`` (to exercise the COALESCE
normalisation, Requirement 3.5).
**Validates: Requirements 3.1, 3.3, 3.4, 3.5, 3.6, 3.7**
"""
from __future__ import annotations
from typing import Any
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.database.trino_client import TrinoClient
# ---------------------------------------------------------------------------
# Stub-DB row container
# ---------------------------------------------------------------------------
class _StubDB:
"""Holds the in-memory competence rows for the fake cursor.
Each row is a ``(ouid, name, top_competency)`` tuple. ``name`` may
be ``None`` (unresolvable competence_id) or ``""`` (empty resolved
name); both must be filtered out by the Trino client. ``top``
may be ``None`` (NULL in the source view) and must be normalised
to ``False``.
"""
def __init__(self, rows: list[tuple[str, Any, Any]]) -> None:
self.rows = rows
def _top_norm(value: Any) -> bool:
"""Mirror ``COALESCE(tc.top_competency, FALSE)`` on the SQL side."""
if value is None:
return False
return bool(value)
def _name_sort_key(name: Any) -> tuple[int, str]:
"""Order keys with ``None`` last, then by string ascending.
Trino orders ``NULL`` last for ``ASC``; we mirror that here so the
simulated cursor produces a deterministic order even for the rows
that the Python-side filter will subsequently drop.
"""
if name is None:
return (1, "")
return (0, str(name))
# ---------------------------------------------------------------------------
# Fake cursor / connection plumbing
# ---------------------------------------------------------------------------
class _FakeCursor:
"""Stub cursor that dispatches by SQL substring match.
For the team-competence SQL the cursor inspects whether the
statement contains ``WHERE tc.ouid = ?`` (single variant) or
``WHERE tc.ouid IN`` (batch variant) and returns rows in the
column shape the production code expects.
Single variant column shape: ``(name, top_competency)``.
Batch variant column shape: ``(ouid, name, top_competency)``.
"""
def __init__(self, stub: _StubDB) -> None:
self.stub = stub
self._next_rows: list[tuple] = []
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
if params is None:
params_tuple: tuple[str, ...] = ()
elif isinstance(params, tuple):
params_tuple = tuple(str(p) for p in params)
else:
params_tuple = tuple(str(p) for p in params)
if (
"teamlandkarte_v_teammeter_team_competences_latest" not in sql
):
self._next_rows = []
return
if "WHERE tc.ouid IN" in sql:
self._next_rows = self._simulate_batch(params_tuple)
return
if "WHERE tc.ouid = ?" in sql:
self._next_rows = self._simulate_single(params_tuple)
return
# Defensive default: the production code only emits the two
# shapes above, so any other shape is unexpected.
self._next_rows = []
def _simulate_single(
self, params: tuple[str, ...]
) -> list[tuple]:
"""Simulate the single-OUID query.
Mirrors the SQL ``ORDER BY CASE WHEN COALESCE(top, FALSE)
THEN 0 ELSE 1 END ASC, c.name ASC`` and emits the two-column
result shape used by ``get_team_competences``.
"""
target = params[0] if params else ""
rows = [r for r in self.stub.rows if r[0] == target]
rows.sort(
key=lambda r: (
0 if _top_norm(r[2]) else 1,
_name_sort_key(r[1]),
)
)
return [(r[1], _top_norm(r[2])) for r in rows]
def _simulate_batch(
self, params: tuple[str, ...]
) -> list[tuple]:
"""Simulate the batch-OUID query.
Mirrors the SQL ``ORDER BY tc.ouid ASC, CASE WHEN COALESCE(top,
FALSE) THEN 0 ELSE 1 END ASC, c.name ASC`` and emits the
three-column result shape used by ``batch_get_team_competences``.
"""
wanted = set(params)
rows = [r for r in self.stub.rows if r[0] in wanted]
rows.sort(
key=lambda r: (
str(r[0]),
0 if _top_norm(r[2]) else 1,
_name_sort_key(r[1]),
)
)
return [(r[0], r[1], _top_norm(r[2])) for r in rows]
def fetchone(self) -> Any: # noqa: ANN401
return self._next_rows[0] if self._next_rows else None
def fetchall(self) -> list[tuple]:
return list(self._next_rows)
class _FakeCursorContext:
"""Context-manager wrapper around a single :class:`_FakeCursor`."""
def __init__(self, cursor: _FakeCursor) -> None:
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _make_client(stub: _StubDB) -> TrinoClient:
"""Build a ``TrinoClient`` whose ``_cursor`` yields a fake cursor."""
cursor = _FakeCursor(stub)
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
client._cursor = lambda: _FakeCursorContext(cursor) # type: ignore[method-assign]
return client
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# A small pool of distinct OUID strings keeps the search space tight and
# makes it likely that several rows share the same ouid (so grouping
# behaviour and ordering inside a group are exercised).
_KNOWN_OUIDS = ("OU1", "OU2", "OU3", "OU4")
_UNKNOWN_OUIDS = ("UNKNOWN1", "UNKNOWN2")
_BLANK_OUIDS = ("", " ")
# ``name`` may be ``None``/``""`` (must be filtered) or any non-empty
# string. We use a small alphabet so duplicates inside an ouid group
# are likely, which exercises the secondary ``name ASC`` sort key.
_name_value = st.one_of(
st.none(),
st.just(""),
st.text(
alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd")),
min_size=1,
max_size=4,
),
)
# ``top_competency`` can be ``None`` (NULL → must normalise to False),
# ``True`` or ``False``. Integers (truthy/falsy) are not used because
# the production SQL exposes a Boolean column after ``COALESCE``.
_top_value = st.one_of(st.none(), st.booleans())
@st.composite
def _stub_and_inputs(draw: st.DrawFn) -> tuple[_StubDB, list[str]]:
"""Generate stub competence rows and a list of input OUIDs.
The strategy intentionally mixes:
- rows for OUIDs in :data:`_KNOWN_OUIDS` (so batch/single both find
data),
- input OUIDs that resolve to known/unknown/blank values (so
Requirements 3.4 and the blank-input short-circuit both fire),
- ``None``/``""`` names and ``None`` top values (Requirements 3.5,
3.6).
Duplicate input OUIDs are allowed: the production batch method
deduplicates via the output dict's keys, so the test asserts the
consistency property only over the set of distinct input OUIDs.
"""
rows = draw(
st.lists(
st.tuples(
st.sampled_from(_KNOWN_OUIDS),
_name_value,
_top_value,
),
min_size=0,
max_size=15,
)
)
candidates = list(_KNOWN_OUIDS) + list(_UNKNOWN_OUIDS) + list(_BLANK_OUIDS)
input_ouids = draw(
st.lists(
st.sampled_from(candidates),
min_size=0,
max_size=6,
)
)
return _StubDB(rows=rows), input_ouids
# ---------------------------------------------------------------------------
# Property
# ---------------------------------------------------------------------------
_SETTINGS = settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@_SETTINGS
@given(payload=_stub_and_inputs())
def test_batch_team_competences_matches_single_variant(
payload: tuple[_StubDB, list[str]],
) -> None:
"""Property 4: Kompetenz-Batch ist konsistent zur Einzel-Variante.
Asserts the four sub-properties listed in the module docstring
against the stub-DB-driven fake cursor.
"""
stub, input_ouids = payload
client = _make_client(stub)
batch = client.batch_get_team_competences(input_ouids)
# ---------------------------------------------------------------
# (1) Batch result has exactly the requested OUIDs as keys
# (Requirement 3.4: empty list for OUIDs without competences).
# ---------------------------------------------------------------
expected_keys = {str(o) for o in input_ouids}
assert set(batch.keys()) == expected_keys, (
"batch_get_team_competences must contain every input ouid; "
f"expected {expected_keys!r}, got {set(batch.keys())!r}"
)
for ouid in input_ouids:
ouid_key = str(ouid)
single = client.get_team_competences(ouid)
batch_entry = batch[ouid_key]
# -----------------------------------------------------------
# (2) Batch-vs-single consistency (Requirements 3.1, 3.3).
# -----------------------------------------------------------
assert batch_entry == single, (
f"batch[{ouid_key!r}] differs from get_team_competences"
f"({ouid!r}): batch={batch_entry!r}, single={single!r}"
)
# -----------------------------------------------------------
# (3) Per-entry invariants (Requirements 3.5, 3.6, 3.7).
# -----------------------------------------------------------
for entry in single:
assert isinstance(entry, dict)
assert set(entry.keys()) == {"name", "top_competency"}
# 3.6: filtered out NULL/empty names.
assert entry["name"], (
"entries with NULL or empty name must be filtered out, "
f"got {entry!r}"
)
# 3.5: NULL top_competency normalised to False.
assert isinstance(entry["top_competency"], bool), (
"top_competency must be a bool after COALESCE, got "
f"{entry['top_competency']!r}"
)
# 3.7: order is (top_competency desc, name asc).
ordering = [
(0 if e["top_competency"] else 1, e["name"]) for e in single
]
assert ordering == sorted(ordering), (
"competence list is not ordered by (top desc, name asc) "
f"for ouid={ouid!r}: {single!r}"
)
# 3.4: blank/unknown OUIDs map to an empty list.
if not str(ouid).strip() or str(ouid) in _UNKNOWN_OUIDS:
assert batch_entry == [], (
"OUID without competences must map to an empty list; "
f"ouid={ouid!r}, got {batch_entry!r}"
)
@@ -0,0 +1,298 @@
# Feature: team-profile-matching, Property 13: Konfig-Validierung von top_competency_weight
"""Property-based test for ``matching.team.top_competency_weight`` validation.
Covers Property 13 from the team-profile-matching design:
1. For every numeric value ``v >= 1.0`` (positive), ``load_config(...)``
returns an ``AppConfig`` with
``cfg.matching.team.top_competency_weight == float(v)``.
2. For every non-numeric value or numeric value ``< 1.0`` (negative),
``load_config(...)`` raises a ``ConfigError`` whose message contains
both the key name ``matching.team.top_competency_weight`` and the
offending value's repr.
3. When the ``[matching.team]`` block (or the key inside it) is absent
from the TOML file, ``cfg.matching.team.top_competency_weight ==
1.5``.
**Validates: Requirements 12.5, 12.6**
"""
from __future__ import annotations
import math
from pathlib import Path
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.config import ConfigError, load_config
# Minimal TOML scaffold containing every section ``load_config`` requires.
# A placeholder ``{team_block}`` is replaced per-test to inject (or omit)
# the ``[matching.team]`` block being exercised.
_CONFIG_TEMPLATE = """\
[database]
backend = "trino"
host = "trino.example"
port = 443
http_scheme = "https"
verify_ssl = true
catalog = "hive"
schema = "tier1_open_lake"
connect_timeout = 10
pool_size = 4
[matching]
competence_weight = 0.8
role_weight = 0.2
require_confirmation = true
[matching.thresholds]
top = 0.8
good = 0.65
partial = 0.5
low = 0.3
[matching.fuzzy]
min_similarity = 0.7
{team_block}
[cache]
db_ttl_hours = 12
search_ttl_minutes = 60
max_size = 100
[azure_openai]
endpoint = "https://example.openai.azure.com"
api_version = "2024-02-15-preview"
chat_deployment = "gpt-4"
show_costs_in_output = false
"""
def _write_config(tmp_path: Path, *, team_block: str) -> Path:
"""Materialize a config.toml file with the given ``team_block``.
``team_block`` is the raw TOML text that replaces the
``{team_block}`` placeholder. Pass ``""`` to omit the
``[matching.team]`` section entirely.
"""
path = tmp_path / "config.toml"
path.write_text(
_CONFIG_TEMPLATE.format(team_block=team_block),
encoding="utf-8",
)
return path
@pytest.fixture(autouse=True)
def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""``load_config`` requires DB credentials and the LLM API key."""
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
def _format_float_for_toml(v: float) -> str:
"""Render a float as a TOML-safe literal.
TOML does not support ``inf`` or ``nan`` literals, but the parsing in
``load_config`` does accept them via Python's ``float()``. To exercise
those values we'd need to feed them through a string. For finite
floats we render with ``repr`` which gives a TOML-compatible literal.
"""
return repr(v)
# --- Positive case: numeric values >= 1.0 are accepted ----------------------
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(
value=st.floats(
min_value=1.0,
max_value=1e6,
allow_nan=False,
allow_infinity=False,
)
)
def test_top_competency_weight_accepts_floats_ge_one(
tmp_path_factory: pytest.TempPathFactory,
value: float,
) -> None:
"""Numeric ``v >= 1.0`` is loaded as ``float(v)``."""
tmp_path = tmp_path_factory.mktemp("cfg")
team_block = (
"[matching.team]\n"
f"top_competency_weight = {_format_float_for_toml(value)}\n"
)
cfg_path = _write_config(tmp_path, team_block=team_block)
cfg = load_config(cfg_path)
assert math.isclose(
cfg.matching.team.top_competency_weight,
float(value),
rel_tol=0.0,
abs_tol=0.0,
)
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(
value=st.integers(min_value=1, max_value=10_000),
)
def test_top_competency_weight_accepts_integers_ge_one(
tmp_path_factory: pytest.TempPathFactory,
value: int,
) -> None:
"""Integer ``v >= 1`` is also accepted and coerced to float."""
tmp_path = tmp_path_factory.mktemp("cfg")
team_block = (
"[matching.team]\n"
f"top_competency_weight = {value}\n"
)
cfg_path = _write_config(tmp_path, team_block=team_block)
cfg = load_config(cfg_path)
assert cfg.matching.team.top_competency_weight == float(value)
# --- Negative case (numeric, < 1.0): ConfigError ----------------------------
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(
value=st.floats(
min_value=-1e6,
max_value=1.0,
exclude_max=True,
allow_nan=False,
allow_infinity=False,
)
)
def test_top_competency_weight_rejects_floats_below_one(
tmp_path_factory: pytest.TempPathFactory,
value: float,
) -> None:
"""Numeric ``v < 1.0`` raises a ``ConfigError`` mentioning key and value."""
tmp_path = tmp_path_factory.mktemp("cfg")
literal = _format_float_for_toml(value)
team_block = (
"[matching.team]\n"
f"top_competency_weight = {literal}\n"
)
cfg_path = _write_config(tmp_path, team_block=team_block)
with pytest.raises(ConfigError) as exc_info:
load_config(cfg_path)
msg = str(exc_info.value)
assert "matching.team.top_competency_weight" in msg
# The offending numeric value must appear in the error message in some
# parsable form. We accept either the float repr or its TOML literal.
assert (str(float(value)) in msg) or (literal in msg)
# --- Negative case (non-numeric): ConfigError -------------------------------
# Printable ASCII strings that survive as TOML basic-string literals
# without escaping (no control chars, no double-quote, no backslash).
_safe_string = st.text(
alphabet=st.characters(
min_codepoint=0x20,
max_codepoint=0x7E,
blacklist_characters='"\\',
),
min_size=0,
max_size=20,
)
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(value=_safe_string)
def test_top_competency_weight_rejects_non_numeric_strings(
tmp_path_factory: pytest.TempPathFactory,
value: str,
) -> None:
"""Non-numeric string values raise a ``ConfigError`` mentioning key + value."""
# Skip strings that ``float()`` would accept (e.g. "1.5", "1e3").
try:
float(value)
return
except (TypeError, ValueError):
pass
tmp_path = tmp_path_factory.mktemp("cfg")
team_block = (
"[matching.team]\n"
f'top_competency_weight = "{value}"\n'
)
cfg_path = _write_config(tmp_path, team_block=team_block)
with pytest.raises(ConfigError) as exc_info:
load_config(cfg_path)
msg = str(exc_info.value)
assert "matching.team.top_competency_weight" in msg
# ``repr`` of the string (with quotes) is part of the error message.
assert repr(value) in msg
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
@given(value=st.booleans())
def test_top_competency_weight_rejects_booleans(
tmp_path_factory: pytest.TempPathFactory,
value: bool,
) -> None:
"""Boolean values raise a ``ConfigError`` even though Python coerces them."""
tmp_path = tmp_path_factory.mktemp("cfg")
literal = "true" if value else "false"
team_block = (
"[matching.team]\n"
f"top_competency_weight = {literal}\n"
)
cfg_path = _write_config(tmp_path, team_block=team_block)
with pytest.raises(ConfigError) as exc_info:
load_config(cfg_path)
msg = str(exc_info.value)
assert "matching.team.top_competency_weight" in msg
assert repr(value) in msg
# --- Default case: missing key -> 1.5 ---------------------------------------
def test_top_competency_weight_defaults_to_one_point_five_when_section_missing(
tmp_path: Path,
) -> None:
"""No ``[matching.team]`` block at all -> default 1.5."""
cfg_path = _write_config(tmp_path, team_block="")
cfg = load_config(cfg_path)
assert cfg.matching.team.top_competency_weight == 1.5
def test_top_competency_weight_defaults_to_one_point_five_when_key_missing(
tmp_path: Path,
) -> None:
"""``[matching.team]`` present but key missing -> default 1.5."""
cfg_path = _write_config(tmp_path, team_block="[matching.team]\n")
cfg = load_config(cfg_path)
assert cfg.matching.team.top_competency_weight == 1.5
@@ -0,0 +1,47 @@
"""Documentation snapshot tests for the team profile matching feature.
These tests assert that the user-facing documentation files contain the
keywords that pin the team-search surface (Profile_Type, find_matching_teams,
team_search, top_competency_weight). They guard against accidental removal
during future doc rewrites.
Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 11.1, 11.2,
11.3, 11.4, 11.5, 11.6, 11.7, 11.8, 11.9
"""
from __future__ import annotations
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
DOC_FILES = (
"docs/architecture.md",
"README.md",
".kiro/agents/teamlandkarte.md",
".github/agents/teamlandkarte_agent.md",
)
REQUIRED_KEYWORDS = (
"Profile_Type",
"find_matching_teams",
"team_search",
"top_competency_weight",
)
@pytest.mark.parametrize("doc_path", DOC_FILES)
@pytest.mark.parametrize("keyword", REQUIRED_KEYWORDS)
def test_doc_contains_team_keyword(doc_path: str, keyword: str) -> None:
"""Each documentation file must mention the team-search keywords verbatim."""
full_path = REPO_ROOT / doc_path
assert full_path.exists(), f"Documentation file missing: {doc_path}"
content = full_path.read_text(encoding="utf-8")
assert keyword in content, (
f"Keyword '{keyword}' missing from documentation file '{doc_path}'."
)
@@ -0,0 +1,393 @@
# Feature: team-profile-matching, Property 12: Verfügbarkeitsfilter werden in Team-Suchen ignoriert
"""Property test that availability filters are ignored for team searches.
For every ``filter_search_results`` invocation on a ``team_search``
cache entry with arbitrary values for ``availability_date_start``,
``availability_date_end`` and ``is_fully_available`` the following must
hold:
1. The set of filtered items is the same as in a call without any
availability filter (same ``role_filter`` and ``competence_filter``).
2. For every availability value that is set, the rendered
``Applied Filters`` table contains a hint that includes the
substring ``"team_search"`` and marks the filter as not effective
(``"nicht wirksam"``).
The test runs ``find_matching_teams`` once per Hypothesis example to
create a ``team_search`` cache entry, then calls
``filter_search_results`` twice on the same ``search_id``: once with
just a ``role_filter`` and once with the same ``role_filter`` plus the
generated availability values. The two filter results must match in
total count, and the second output must carry the ``team_search``
hint for every availability value that was actively passed.
**Validates: Requirements 6.7, 8.5, 8.6**
"""
from __future__ import annotations
import json
import re
from datetime import date
from typing import Any
from unittest.mock import AsyncMock
import pytest
from hypothesis import HealthCheck, assume, given, settings
from hypothesis import strategies as st
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
def _make_teams() -> list[Team]:
"""Three teams with diverse focus_name values.
The diversity ensures that ``role_filter="Backend"`` produces a
non-trivial subset (at least one match, at least one non-match)
so the property check is not vacuous.
"""
return [
Team(
team_id="t1",
ouid="ou-1",
team_name="Team Alpha",
focus_name="Backend Developer",
about_us="We build backends.",
offerings="APIs and services.",
interests="Distributed systems.",
competences=[
TeamCompetence(name="python", top_competency=True),
TeamCompetence(name="fastapi", top_competency=False),
],
references=[
TeamReference(partner_name="DB Cargo", projects="ETL"),
],
),
Team(
team_id="t2",
ouid="ou-2",
team_name="Team Beta",
focus_name="Frontend Developer",
about_us="We build frontends.",
offerings="UIs and apps.",
interests="Web technologies.",
competences=[
TeamCompetence(name="javascript", top_competency=True),
TeamCompetence(name="react", top_competency=False),
],
references=[],
),
Team(
team_id="t3",
ouid="ou-3",
team_name="Team Gamma",
focus_name="Data Engineer",
about_us="We build pipelines.",
offerings="Data flows.",
interests="Big data.",
competences=[
TeamCompetence(name="python", top_competency=False),
TeamCompetence(name="spark", top_competency=False),
],
references=[],
),
]
class _FakeDB:
"""Minimal fake DB returning a small fixed set of teams."""
def test_connection(self) -> None:
"""No-op so server start-up succeeds."""
return
def get_table_columns(self, table: str) -> list[str]:
"""Return the columns expected by the schema verifier."""
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
"""Return a non-empty role list to satisfy server start-up."""
return ["Backend Developer", "Frontend Developer", "Data Engineer"]
def get_all_competence_names(self) -> list[str]:
"""Return the competence pool used in the fake teams."""
return ["python", "fastapi", "javascript", "react", "spark"]
def get_open_tasks(self, limit: int = 20):
"""No tasks needed for this property test."""
return []
def get_all_capacities_with_competences(self):
"""No capacities needed for this property test."""
return []
def get_recent_free_capacities(self, limit: int = 20):
"""No free capacities needed for this property test."""
return []
def get_all_teams(self) -> list[Team]:
"""Return the fixed three-team fixture."""
return _make_teams()
def get_team_by_id(self, team_id): # pragma: no cover
"""Look up a single team by id from the fixture."""
for team in _make_teams():
if team.team_id == str(team_id):
return team
return None
def _result_to_text(result: Any) -> str:
"""Extract the text payload from an MCP tool result."""
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
"""Invoke an MCP tool and return its rendered text output."""
return _result_to_text(await srv.call_tool(name, args))
def _extract_search_id(output: str) -> str:
"""Parse the ``SEARCH_ID=<uuid>`` line out of a tool output."""
for line in output.splitlines():
if line.startswith("SEARCH_ID="):
return line[len("SEARCH_ID="):].strip()
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
def _extract_meta(output: str) -> dict[str, Any]:
"""Parse the ``META=<json>`` line out of a tool output."""
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META="):])
raise AssertionError(f"no META= line in tool output:\n{output}")
def _extract_applied_filter_row(output: str, filter_name: str) -> str:
"""Return the ``Value`` cell for a row in the Applied Filters table."""
# The filter table is a markdown table where each data row looks
# like ``| <filter_name> | <value> |``. Extract the value cell.
pattern = rf"^\|\s*{re.escape(filter_name)}\s*\|\s*(.*?)\s*\|\s*$"
rgx = re.compile(pattern, re.MULTILINE)
match = rgx.search(output)
if match is None:
raise AssertionError(
f"missing Applied Filters row for {filter_name!r}:\n{output}"
)
return match.group(1)
def _build_test_server(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Any:
"""Construct a server backed by ``_FakeDB`` and a mocked LLM."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
)
# The score path needs ``compute_role_similarity`` which calls the
# LLM client. Mock it to avoid network and make scoring stable.
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
AsyncMock(
return_value=(
'{"similarity": 1.0, "category": "Top", "rationale": "ok"}'
)
),
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
return build_server(str(cfg))
# Strategies: ISO date strings or None for the date filters, plus a
# random boolean for ``is_fully_available``. We require at least one of
# the three values to be "active" so the property's clause about the
# ``team_search`` hint is exercised in every example.
_DATE_STRATEGY = st.one_of(
st.none(),
st.dates(
min_value=date(2020, 1, 1),
max_value=date(2030, 12, 31),
).map(lambda d: d.isoformat()),
)
@settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@given(
avail_start=_DATE_STRATEGY,
avail_end=_DATE_STRATEGY,
is_fully_available=st.booleans(),
)
@pytest.mark.asyncio
async def test_team_search_ignores_availability_filters(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
avail_start: str | None,
avail_end: str | None,
is_fully_available: bool,
) -> None:
"""Property 12: availability filters are ignored for team searches.
- The filtered total must match the no-availability call
(item set equality via ``META["total"]``).
- For every availability value that is actively passed, the
``Applied Filters`` table must contain a ``team_search`` hint
including ``"nicht wirksam"``.
"""
# At least one availability value must be active so the second
# clause of the property is non-vacuous.
assume(avail_start or avail_end or is_fully_available)
srv = _build_test_server(monkeypatch, tmp_path)
# Step 1: create a team_search cache entry. The role/competence
# arguments overlap the fixture so the search succeeds without
# validation errors.
search_out = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "Backend Developer",
"competences": ["python"],
"matching_method": "score",
},
)
search_id = _extract_search_id(search_out)
search_meta = _extract_meta(search_out)
assert search_meta.get("search_type") == "team_search", (
f"expected search_type='team_search', got META={search_meta!r}"
)
# Step 2: filter without any availability filter. ``role_filter``
# is the only active filter so the call satisfies the
# "at least one filter" guard.
base_out = await _call_tool(
srv,
"filter_search_results",
{
"search_id": search_id,
"role_filter": "Backend",
},
)
base_meta = _extract_meta(base_out)
base_total = int(base_meta.get("total", -1))
assert base_total >= 0, (
f"missing/invalid total in base META={base_meta!r}"
)
# Step 3: filter with the same ``role_filter`` PLUS the random
# availability filter values.
avail_args: dict[str, Any] = {
"search_id": search_id,
"role_filter": "Backend",
"is_fully_available": is_fully_available,
}
if avail_start is not None:
avail_args["availability_date_start"] = avail_start
if avail_end is not None:
avail_args["availability_date_end"] = avail_end
avail_out = await _call_tool(srv, "filter_search_results", avail_args)
avail_meta = _extract_meta(avail_out)
avail_total = int(avail_meta.get("total", -1))
assert avail_total >= 0, (
f"missing/invalid total in availability META={avail_meta!r}"
)
# Property clause 1: the filtered item set must be unchanged.
assert avail_total == base_total, (
f"availability filter changed total: base={base_total}, "
f"with availability={avail_total}; "
f"avail_args={avail_args!r}"
)
# Property clause 2: each actively-passed availability value must
# be reflected in the Applied Filters table with a hint that
# contains ``team_search`` and marks the filter as not effective.
def _check_hint(filter_name: str) -> None:
cell = _extract_applied_filter_row(avail_out, filter_name)
assert "team_search" in cell, (
f"Applied Filters row {filter_name!r} is missing the "
f"'team_search' substring; cell={cell!r}\n"
f"output:\n{avail_out}"
)
assert "nicht wirksam" in cell, (
f"Applied Filters row {filter_name!r} does not mark the "
f"filter as not effective; cell={cell!r}\n"
f"output:\n{avail_out}"
)
if avail_start is not None:
_check_hint("availability_date_start")
if avail_end is not None:
_check_hint("availability_date_end")
if is_fully_available:
_check_hint("is_fully_available")
@@ -0,0 +1,445 @@
# Feature: team-profile-matching, Task 11.2
"""End-to-end integration smoke test for ``find_matching_teams`` in
``llm_fulltext`` mode.
Exercises the full request path through ``build_server`` with a
fully-mocked ``DBClient`` and a ``MagicMock``-backed
``AzureOpenAIClient``. The mock returns a valid LLM JSON payload for
two of the three teams and raises :class:`AzureAPIError` for the third
so the integration verifies both happy and error paths:
find_matching_teams (llm_fulltext)
-> LlmFulltextMatcher.match_teams
-> SearchCache.store_search (search_type="team_search",
matching_method="llm_fulltext")
-> get_results_by_category (renders LLM-mode columns)
-> filter_search_results (role_filter)
The test verifies that:
* A simulated LLM error places the affected team into the response's
``## Errors`` section and the persisted ``errors`` list (Anforderung
7.6).
* The persisted ``SearchCache`` payload carries
``search_type == "team_search"`` and
``matching_method == "llm_fulltext"`` (Anforderungen 8.1, 8.2, 8.3).
* The Markdown output contains the LLM-mode column layout for teams
(``Team Name``, ``Schwerpunkt``, ``Top-Kompetenzen``, ``Category``,
``Begründung``) (Anforderung 8.4).
* Subsequent calls to ``get_results_by_category`` and
``filter_search_results`` operate on the persisted ``team_search``
entry and reflect the same markers.
_Requirements: 7.1, 7.6, 8.1, 8.2, 8.4_
"""
from __future__ import annotations
import json
from typing import Any
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.cache.search_cache import SearchCache
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_search_id(output: str) -> str:
for line in output.splitlines():
if line.startswith("SEARCH_ID="):
return line[len("SEARCH_ID="):].strip()
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META="):])
raise AssertionError(f"no META= line in tool output:\n{output}")
def _make_teams() -> list[Team]:
"""Three deterministic teams used to exercise both LLM outcomes."""
return [
Team(
team_id="t1",
ouid="ou-1",
team_name="Team Alpha",
focus_name="Backend Developer",
about_us="We build robust backends.",
offerings="APIs and services.",
interests="Distributed systems.",
competences=[
TeamCompetence(name="python", top_competency=True),
TeamCompetence(name="fastapi", top_competency=False),
],
references=[
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
],
),
Team(
team_id="t2",
ouid="ou-2",
team_name="Team Beta",
focus_name="Frontend Developer",
about_us="We build slick UIs.",
offerings="Web apps.",
interests="Web technologies.",
competences=[
TeamCompetence(name="javascript", top_competency=True),
],
references=[],
),
Team(
team_id="t3",
ouid="ou-3",
team_name="Team Gamma",
focus_name="Data Engineer",
about_us="We build pipelines.",
offerings="Data flows.",
interests="Big data.",
competences=[
TeamCompetence(name="spark", top_competency=False),
],
references=[],
),
]
class _FakeDB:
"""Minimal fake ``DBClient`` for the LLM team integration smoke test."""
def __init__(self, teams: list[Team]) -> None:
self._teams = list(teams)
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Backend Developer", "Frontend Developer", "Data Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["python", "fastapi", "javascript", "spark"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return []
def get_recent_free_capacities(self, limit: int = 20):
return []
def get_all_teams(self) -> list[Team]:
return list(self._teams)
def get_team_by_id(self, team_id): # pragma: no cover
for t in self._teams:
if t.team_id == str(team_id):
return t
return None
def _patch_capturing_store(
monkeypatch: pytest.MonkeyPatch,
) -> list[dict[str, Any]]:
"""Capture every payload passed to ``SearchCache.store_search``."""
captured: list[dict[str, Any]] = []
original = SearchCache.store_search
def _capturing(self, *, task_id, requirements, results):
captured.append(results)
return original(
self,
task_id=task_id,
requirements=requirements,
results=results,
)
monkeypatch.setattr(SearchCache, "store_search", _capturing)
return captured
def _build_server_with_llm_mock(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
*,
team_responses: dict[str, Any],
) -> Any:
"""Build a server backed by ``_FakeDB`` and a mocked LLM.
``team_responses`` maps each ``team_id`` (str) to either a JSON
string (success) or an :class:`AzureAPIError` instance (simulated
failure). The fake ``chat_completion`` parses the team_id out of
the prompt's ``ID: <team_id>`` line that
:func:`_build_user_prompt_team_for_task` embeds, so the mapping is
deterministic regardless of ``asyncio.gather`` scheduling.
"""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
fake_db = _FakeDB(_make_teams())
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
)
async def _fake_chat(self, system: str, user: str) -> str: # noqa: ARG001
team_id = ""
for line in user.splitlines():
if line.startswith("ID: "):
team_id = line[len("ID: "):].strip()
break
outcome = team_responses.get(team_id)
if isinstance(outcome, BaseException):
raise outcome
if outcome is None:
# Defensive default: any unmapped team is reported as
# ``Irrelevant`` so the test still terminates.
return json.dumps(
{"category": "Irrelevant", "rationale": "default"},
ensure_ascii=False,
)
return outcome
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client."
"AzureOpenAIClient.chat_completion",
_fake_chat,
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
return build_server(str(cfg))
@pytest.mark.asyncio
async def test_e2e_find_matching_teams_llm_fulltext_full_path(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""End-to-end smoke test for ``find_matching_teams`` (llm_fulltext).
Validates: requirements 7.1 (LLM-categorisation per team), 7.6
(LLM error surfaces in ``errors`` list), 8.1, 8.2 (cache markers)
and 8.4 (LLM-mode column layout).
"""
# Two valid LLM payloads and one simulated failure.
team_responses: dict[str, Any] = {
"t1": json.dumps(
{"category": "Top", "rationale": "Backend match."},
ensure_ascii=False,
),
"t2": json.dumps(
{"category": "Partial", "rationale": "Frontend overlap."},
ensure_ascii=False,
),
"t3": AzureAPIError("LLM call failed"),
}
captured = _patch_capturing_store(monkeypatch)
srv = _build_server_with_llm_mock(
monkeypatch, tmp_path, team_responses=team_responses
)
# ------------------------------------------------------------------
# 1) find_matching_teams (llm_fulltext mode)
# ------------------------------------------------------------------
out = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "Backend Developer",
"competences": ["python"],
"matching_method": "llm_fulltext",
},
)
# SEARCH_ID + META markers (Anforderungen 8.1, 8.3).
search_id = _extract_search_id(out)
meta = _extract_meta(out)
assert meta.get("search_id") == search_id
assert meta.get("search_type") == "team_search"
assert meta.get("matching_method") == "llm_fulltext"
# The errored team (t3) must be visible in the rendered output via
# the ``## Errors`` section (Anforderung 7.6).
assert "## Errors" in out, out
assert "t3" in out, out
# Team t3 must NOT appear in the rendered category table because
# errored teams never end up in by_category.
# (We check the persisted payload below for the strict invariant.)
# The team-search LLM column layout must be present
# (Anforderung 8.4).
for header in (
"Team Name",
"Schwerpunkt",
"Top-Kompetenzen",
"Category",
"Begründung",
):
assert header in out, (
f"missing team-search llm-mode header {header!r} in:\n{out}"
)
# The persisted SearchCache payload carries the team_search markers
# and the errors list (Anforderungen 7.6, 8.2).
assert captured, "SearchCache.store_search was never called"
payload = captured[-1]
assert payload["search_type"] == "team_search"
assert payload["matching_method"] == "llm_fulltext"
errors = payload.get("errors") or []
error_ids = {e["item_id"] for e in errors}
assert error_ids == {"t3"}, errors
by_category = payload.get("by_category") or {}
flat_items = [item for items in by_category.values() for item in items]
flat_ids = {str(it.get("team_id")) for it in flat_items}
# The two successful teams are present, the errored team is not.
assert flat_ids == {"t1", "t2"}, flat_ids
# by_category must NOT contain the errored team.
assert "t3" not in flat_ids
# Each persisted item carries category + rationale fields.
for item in flat_items:
assert "team_id" in item
assert "category" in item
assert "rationale" in item
assert isinstance(item["rationale"], str)
assert item["rationale"].strip()
# ------------------------------------------------------------------
# 2) get_results_by_category — renders the LLM-mode team table.
# ------------------------------------------------------------------
default_category = str(meta.get("default_category") or "Top")
cat_out = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"category": default_category,
"page": 1,
"page_size": 20,
},
)
cat_meta = _extract_meta(cat_out)
assert cat_meta.get("search_type") == "team_search"
assert cat_meta.get("matching_method") == "llm_fulltext"
assert cat_meta.get("search_id") == search_id
for header in (
"Team Name",
"Schwerpunkt",
"Top-Kompetenzen",
"Category",
"Begründung",
):
assert header in cat_out, (
f"missing team-search llm-mode header {header!r} in "
f"get_results_by_category output:\n{cat_out}"
)
# Score-only columns must NOT appear in LLM-mode tables.
assert "Role Score" not in cat_out
assert "Overall Score" not in cat_out
# ------------------------------------------------------------------
# 3) filter_search_results — round-trip on the team_search entry.
# ------------------------------------------------------------------
filtered_out = await _call_tool(
srv,
"filter_search_results",
{
"search_id": search_id,
"role_filter": "Backend",
},
)
assert "FILTER_ID=" in filtered_out, filtered_out
filter_meta = _extract_meta(filtered_out)
assert filter_meta.get("search_type") == "team_search"
assert filter_meta.get("matching_method") == "llm_fulltext"
assert filter_meta.get("status") == "ok"
# Only Team Alpha's focus_name matches "Backend"; t3 is in errors
# (not in by_category), so the filtered total must be exactly 1.
assert filter_meta.get("total") == 1, filter_meta
assert "## Applied Filters" in filtered_out
assert "role_filter" in filtered_out
assert "Backend" in filtered_out
# LLM-mode preview must include the Begründung column.
assert "Begründung" in filtered_out
assert "Filtered total results: 1" in filtered_out
@@ -0,0 +1,470 @@
# Feature: team-profile-matching, Task 11.1
"""End-to-end integration smoke test for ``find_matching_teams`` in
``score`` mode.
Exercises the full request path through ``build_server`` with a
fully-mocked ``DBClient`` and a stubbed ``AzureOpenAIClient``:
find_matching_teams (score)
-> Matcher.match_teams (uses SimilarityEngine)
-> SearchCache.store_search (search_type="team_search")
-> get_results_by_category (renders team-search columns)
-> filter_search_results (role_filter)
The test verifies that:
* The persisted ``SearchCache`` payload carries
``search_type == "team_search"`` and ``matching_method == "score"``.
* The Markdown output emits the ``SEARCH_ID=`` and ``META=`` lines and
the response renders a per-category table with the team-specific
columns including ``Role Score``, ``Competence Score``,
``Overall Score`` and ``Category``.
* Subsequent calls to ``get_results_by_category`` and
``filter_search_results`` work end-to-end against the persisted
``team_search`` entry and reflect the same ``search_type`` and
``matching_method`` markers.
_Requirements: 6.1, 8.1, 8.2, 8.4_
"""
from __future__ import annotations
import json
from typing import Any
import pytest
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.cache.search_cache import SearchCache
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_search_id(output: str) -> str:
for line in output.splitlines():
if line.startswith("SEARCH_ID="):
return line[len("SEARCH_ID="):].strip()
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META="):])
raise AssertionError(f"no META= line in tool output:\n{output}")
def _make_teams() -> list[Team]:
"""Three deterministic teams with different focus areas.
The first team's competences and ``focus_name`` match the request
exactly so it should land in a high category. The other two teams
have orthogonal focus areas, ensuring at least one team lies in a
different bucket - which makes the role_filter step in this test
meaningful.
"""
return [
Team(
team_id="t1",
ouid="ou-1",
team_name="Team Alpha",
focus_name="Backend Developer",
about_us="We build robust backends.",
offerings="APIs and services.",
interests="Distributed systems.",
competences=[
TeamCompetence(name="python", top_competency=True),
TeamCompetence(name="fastapi", top_competency=False),
],
references=[
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
],
),
Team(
team_id="t2",
ouid="ou-2",
team_name="Team Beta",
focus_name="Frontend Developer",
about_us="We build slick UIs.",
offerings="Web apps.",
interests="Web technologies.",
competences=[
TeamCompetence(name="javascript", top_competency=True),
],
references=[],
),
Team(
team_id="t3",
ouid="ou-3",
team_name="Team Gamma",
focus_name="Data Engineer",
about_us="We build pipelines.",
offerings="Data flows.",
interests="Big data.",
competences=[
TeamCompetence(name="spark", top_competency=False),
],
references=[],
),
]
class _FakeDB:
"""Minimal fake ``DBClient`` for the team integration smoke test."""
def __init__(self, teams: list[Team]) -> None:
self._teams = list(teams)
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Backend Developer", "Frontend Developer", "Data Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["python", "fastapi", "javascript", "spark"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return []
def get_recent_free_capacities(self, limit: int = 20):
return []
def get_all_teams(self) -> list[Team]:
return list(self._teams)
def get_team_by_id(self, team_id): # pragma: no cover
for t in self._teams:
if t.team_id == str(team_id):
return t
return None
class _StubSimilarityEngine:
"""Deterministic ``SimilarityEngine`` stand-in.
Returns a perfect role similarity when the role names match
case-insensitively (and zero otherwise) and a per-competence
score of 1.0 when the required name appears (case-insensitively)
in the candidate's competences.
The return shape mirrors the production
:meth:`SimilarityEngine.compute_competence_similarity` contract:
``dict[required, {"score", "best_match", "rationale"}]`` (see
``matching/similarity.py``). Avoiding any LLM round-trip keeps
this integration test fully offline and deterministic.
"""
async def compute_role_similarity(
self, requested: str, candidate: str
) -> float:
a = (requested or "").strip().lower()
b = (candidate or "").strip().lower()
if not a or not b:
return 0.0
return 1.0 if a == b else 0.0
async def compute_competence_similarity(
self,
required: list[str],
candidate: list[str],
global_index: Any = None,
) -> dict[str, dict[str, object]]:
"""Stub of the production BM25+RRF similarity.
For every required competence, returns ``score=1.0`` plus the
actual candidate name when there is an exact case-insensitive
match, and ``score=0.0`` with ``best_match=None`` otherwise.
``global_index`` is accepted for signature compatibility but
not used.
"""
avail_norm = {a.strip().lower(): a for a in (candidate or []) if a}
result: dict[str, dict[str, object]] = {}
for req in required or []:
key = (req or "").strip().lower()
if key in avail_norm:
result[req] = {
"score": 1.0,
"best_match": avail_norm[key],
"rationale": "exact match (stub)",
}
else:
result[req] = {
"score": 0.0,
"best_match": None,
"rationale": "no match (stub)",
}
return result
def _build_server_with_stub_similarity(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> Any:
"""Build a server backed by ``_FakeDB`` and a stubbed
``SimilarityEngine`` so the score path is fully offline.
The score path uses :class:`SimilarityEngine` (which would otherwise
call the LLM through ``compute_role_similarity``). Replacing the
engine with the stub above keeps the integration test deterministic
without depending on the BM25 corpus or LLM caching layers.
"""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
fake_db = _FakeDB(_make_teams())
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
)
# Replace the SimilarityEngine class with the stub so build_server
# constructs a deterministic engine with zero network surface.
monkeypatch.setattr(
mcp_mod, "SimilarityEngine", lambda **_kw: _StubSimilarityEngine()
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
return build_server(str(cfg))
def _patch_capturing_store(
monkeypatch: pytest.MonkeyPatch,
) -> list[dict[str, Any]]:
"""Capture every payload passed to ``SearchCache.store_search``."""
captured: list[dict[str, Any]] = []
original = SearchCache.store_search
def _capturing(self, *, task_id, requirements, results):
captured.append(results)
return original(
self,
task_id=task_id,
requirements=requirements,
results=results,
)
monkeypatch.setattr(SearchCache, "store_search", _capturing)
return captured
@pytest.mark.asyncio
async def test_e2e_find_matching_teams_score_mode_full_path(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""End-to-end smoke test for ``find_matching_teams`` in ``score`` mode.
Validates: requirements 6.1 (score-based team matching), 8.1
(search_id propagation), 8.2 (search_type='team_search' marker in
SearchCache) and 8.4 (team-specific column layout in
``get_results_by_category``).
"""
captured = _patch_capturing_store(monkeypatch)
srv = _build_server_with_stub_similarity(monkeypatch, tmp_path)
# ------------------------------------------------------------------
# 1) find_matching_teams (score mode)
# ------------------------------------------------------------------
out = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "Backend Developer",
"competences": ["python"],
"matching_method": "score",
},
)
# SEARCH_ID and META lines exist (Anforderung 8.1).
search_id = _extract_search_id(out)
meta = _extract_meta(out)
assert meta.get("search_id") == search_id
assert meta.get("search_type") == "team_search"
assert meta.get("matching_method") == "score"
# Score-mode column headers must be present in the rendered top
# category table (Anforderung 6.6 / 8.4). The exact category bucket
# is data-dependent, but at least one ``## <Category> Results``
# section must appear.
assert any(
f"## {cat} Results" in out
for cat in ("Top", "Good", "Partial", "Low", "Irrelevant")
), out
for header in (
"Team Name",
"Schwerpunkt",
"Top-Kompetenzen",
"Role Score",
"Competence Score",
"Overall Score",
"Category",
):
assert header in out, (
f"missing team-search score-mode header {header!r} in:\n{out}"
)
# The persisted SearchCache payload carries the team_search markers
# (Anforderung 8.2).
assert captured, "SearchCache.store_search was never called"
payload = captured[-1]
assert payload["search_type"] == "team_search"
assert payload["matching_method"] == "score"
assert "by_category" in payload
# Score mode must emit per-team score fields on the persisted items.
flat_items = [
item
for items in payload["by_category"].values()
for item in items
]
assert flat_items, (
"score-mode payload must contain at least one persisted team item"
)
for item in flat_items:
assert "team_id" in item, item
assert "team_name" in item, item
assert "focus_name" in item, item
assert "competences" in item, item
assert "competence_score" in item, item
assert "role_score" in item, item
assert "overall_score" in item, item
assert "category" in item, item
# ------------------------------------------------------------------
# 2) get_results_by_category — uses the cached payload, must render
# the team-specific column layout (Anforderung 8.4).
# ------------------------------------------------------------------
default_category = str(meta.get("default_category") or "Top")
cat_out = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"category": default_category,
"page": 1,
"page_size": 20,
},
)
cat_meta = _extract_meta(cat_out)
assert cat_meta.get("search_type") == "team_search"
assert cat_meta.get("matching_method") == "score"
assert cat_meta.get("search_id") == search_id
for header in (
"Team Name",
"Schwerpunkt",
"Top-Kompetenzen",
"Role Score",
"Competence Score",
"Overall Score",
"Category",
):
assert header in cat_out, (
f"missing team-search header {header!r} in "
f"get_results_by_category output:\n{cat_out}"
)
# ------------------------------------------------------------------
# 3) filter_search_results — applying ``role_filter='Backend'``
# must operate against the team's ``focus_name`` and round-trip
# successfully (Anforderung 8.4 / 8.5).
# ------------------------------------------------------------------
filtered_out = await _call_tool(
srv,
"filter_search_results",
{
"search_id": search_id,
"role_filter": "Backend",
},
)
assert "FILTER_ID=" in filtered_out, filtered_out
filter_meta = _extract_meta(filtered_out)
assert filter_meta.get("search_type") == "team_search"
assert filter_meta.get("matching_method") == "score"
assert filter_meta.get("status") == "ok"
# The role_filter only matches Team Alpha's ``focus_name`` so the
# filtered total must be exactly 1.
assert filter_meta.get("total") == 1, filter_meta
# The Applied Filters table must show the role_filter we passed and
# the filtered preview must include the team-search column layout.
assert "## Applied Filters" in filtered_out
assert "role_filter" in filtered_out
assert "Backend" in filtered_out
assert "Team Name" in filtered_out
assert "Schwerpunkt" in filtered_out
assert "Filtered total results: 1" in filtered_out
@@ -0,0 +1,225 @@
# Feature: team-profile-matching, Property 9: LLM-Pfad ist eine vollständige Partition mit gültigen Kategorien
"""Property-based test for the team LLM-fulltext matcher partition.
For any list of teams and any deterministic LLM error mask
(``mask[i] == True`` simulates an Azure failure for ``team_id == str(i)``,
``mask[i] == False`` returns a valid JSON category), the result of
:meth:`LlmFulltextMatcher.match_teams` MUST be a strict partition of the
input set with valid categories and stable ordering.
The fake :class:`AzureOpenAIClient` looks up the failure flag by
``team_id``: the matcher submits its ``chat_completion`` calls
concurrently via :func:`asyncio.gather` (throttled by an
:class:`asyncio.Semaphore`), so a positional counter would be
order-dependent. Resolving the mask through the ``ID: <team_id>`` line
embedded in every user prompt (see
``_build_user_prompt_team_for_task`` in
``matching/llm_fulltext_matcher.py``) keeps the test deterministic
regardless of the actual LLM completion order.
The properties checked are:
1. Completeness: every team appears either in ``by_category`` or in
``errors`` (sum equals ``len(teams)``).
2. Uniqueness: no team_id appears twice across ``by_category`` and
``errors``.
3. Valid categories: every ``LlmFulltextItem.category`` is one of the
five canonical categories.
4. Stable per-category sort: items inside each ``by_category`` bucket
are sorted by ``item_id`` (team_id) ascending.
5. Success/error split: successful items carry a non-empty rationale
string; teams with simulated LLM failures appear only in
``errors`` and never in ``by_category``.
**Validates: Requirements 7.1, 7.2, 7.3, 7.6, 7.7**
"""
from __future__ import annotations
import asyncio
import json
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
_ALLOWED_CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant")
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _MaskLlm:
"""Fake ``AzureOpenAIClient`` driven by a per-team_id boolean mask.
Resolves the failure flag through the ``ID: <team_id>`` line that the
matcher embeds in every user prompt, so the behaviour is independent
of the (concurrent) call order produced by ``asyncio.gather``.
"""
def __init__(self, mask_by_id: dict[str, bool]) -> None:
self._mask_by_id = mask_by_id
async def chat_completion(self, system: str, user: str) -> str:
team_id = ""
for line in user.splitlines():
if line.startswith("ID: "):
team_id = line[len("ID: ") :].strip()
break
if self._mask_by_id.get(team_id, False):
raise AzureAPIError("simulated")
return json.dumps(
{"category": "Good", "rationale": "stub rationale"}
)
class _StubDb:
"""Minimal ``DBClient`` double.
``LlmFulltextMatcher.match_teams`` does not perform any database
lookups (the :class:`Team` already carries its competences and
references), so the stub does not need to implement any method
bodies. The constructor still requires a ``db`` argument, which is
why the stub exists.
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_teams(n: int) -> list[Team]:
"""Build ``n`` teams with deterministic, unique ``team_id`` values."""
return [
Team(
team_id=str(i),
ouid=f"ou-{i}",
team_name=f"Team {i}",
focus_name="Cloud",
about_us="",
offerings="",
interests="",
competences=[
TeamCompetence(name="Python", top_competency=True),
],
references=[
TeamReference(
partner_name=f"Partner {i}",
projects=f"Project {i}",
),
],
)
for i in range(n)
]
def _make_task_profile() -> TaskProfile:
return TaskProfile(
id="t1",
title="Cloud Architect",
description="Design and implement cloud platforms.",
skills=["Python", "Kubernetes"],
)
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(mask=st.lists(st.booleans(), min_size=1, max_size=20))
def test_team_llm_fulltext_partition(mask: list[bool]) -> None:
"""Property 9: the LLM team path is a complete, deterministic partition.
Validates Requirements 7.1, 7.2, 7.3, 7.6, 7.7:
completeness, uniqueness, valid categories, per-category sort and
the success/error split.
"""
teams = _make_teams(len(mask))
mask_by_id = {team.team_id: flag for team, flag in zip(teams, mask)}
matcher = LlmFulltextMatcher(
db=_StubDb(), # type: ignore[arg-type]
client=_MaskLlm(mask_by_id), # type: ignore[arg-type]
)
result = asyncio.run(
matcher.match_teams(
task_profile=_make_task_profile(),
teams=teams,
)
)
# All five canonical buckets are always present in by_category.
assert set(result.by_category.keys()) == set(_ALLOWED_CATEGORIES)
# Property 1: completeness — every team appears exactly once across
# by_category and errors (Requirement 7.1, 7.6).
category_ids: list[str] = [
item.item_id
for items in result.by_category.values()
for item in items
]
error_ids: list[str] = [err.item_id for err in result.errors]
total = len(category_ids) + len(error_ids)
assert total == len(teams), (
f"Expected {len(teams)} total items, got {total} "
f"({len(category_ids)} categorized + {len(error_ids)} errors)"
)
# Property 2: uniqueness — no team_id is duplicated.
all_ids = category_ids + error_ids
assert len(all_ids) == len(set(all_ids)), (
f"Duplicate team_ids detected: {len(all_ids)} total vs "
f"{len(set(all_ids))} unique"
)
# Property 3: every category value is one of the canonical five
# (Requirement 7.2).
for items in result.by_category.values():
for item in items:
assert item.category in _ALLOWED_CATEGORIES, (
f"Unexpected category: {item.category!r}"
)
# Property 4: per-category items are sorted ascending by item_id
# (Requirement 7.7).
for category, items in result.by_category.items():
item_ids = [item.item_id for item in items]
assert item_ids == sorted(item_ids), (
f"Items in category {category!r} are not sorted by "
f"item_id ascending: {item_ids}"
)
# Property 5a: every successfully categorized team has a non-empty
# rationale string (Requirement 7.3).
for items in result.by_category.values():
for item in items:
assert isinstance(item.rationale, str)
assert item.rationale.strip(), (
f"Empty rationale for categorized team {item.item_id}"
)
# Property 5b: success/error split matches the mask exactly
# (Requirement 7.6). Failing-mask teams must not appear in
# by_category, succeeding-mask teams must not appear in errors.
expected_error_ids = {tid for tid, flag in mask_by_id.items() if flag}
expected_success_ids = {tid for tid, flag in mask_by_id.items() if not flag}
assert set(error_ids) == expected_error_ids, (
f"Mismatch between expected and observed error team_ids: "
f"expected {expected_error_ids}, got {set(error_ids)}"
)
assert set(category_ids) == expected_success_ids, (
f"Mismatch between expected and observed categorized team_ids: "
f"expected {expected_success_ids}, got {set(category_ids)}"
)
@@ -0,0 +1,186 @@
# Feature: team-profile-matching, Property 10: Ungültige LLM-Kategorien fallen auf Irrelevant zurück
"""Property-based test for invalid LLM categories in the team path.
For any LLM response ``{"category": c, "rationale": r}`` where ``c`` is
not one of the canonical categories
``("Top", "Good", "Partial", "Low", "Irrelevant")`` (case-insensitive,
trimmed), the team-side matcher must:
1. Route the team exclusively to the ``Irrelevant`` bucket of
``result.by_category``.
2. Append a deterministic hint of the form
``[Hinweis: ungültige LLM-Kategorie: ...]`` to the rationale, so the
result remains traceable to the offending LLM response.
3. Preserve the original (trimmed) rationale text alongside the hint.
4. Not record the team in ``result.errors`` — invalid categories are a
re-routed result, not a failure.
The test exercises the public :meth:`LlmFulltextMatcher.match_teams`
path with a single team to validate the matcher behaviour end-to-end
(not just the ``normalize_category`` helper). The
``AzureOpenAIClient`` is replaced by a minimal in-memory double that
returns the invalid-category JSON; the ``DBClient`` is unused by
``match_teams`` because :class:`Team` already carries its competences
and references.
**Validates: Requirements 7.5**
"""
from __future__ import annotations
import asyncio
import json
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
_ALLOWED_LOWER = {"top", "good", "partial", "low", "irrelevant"}
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
# Any short string whose trimmed/lowercased form is NOT one of the
# canonical categories. ``normalize_category`` will treat such values as
# invalid and the matcher must route them to ``Irrelevant``.
_invalid_category = st.text(max_size=20).filter(
lambda s: s.strip().lower() not in _ALLOWED_LOWER
)
# Free-form rationale text the fake LLM returns alongside the invalid
# category. The matcher must keep the original (non-whitespace) content
# in the stored rationale.
_rationale = st.text(max_size=120)
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _StubLlm:
"""Minimal ``AzureOpenAIClient`` double returning a fixed JSON payload.
Mirrors the fake-client pattern used in
:mod:`tests.test_team_llm_fulltext_partition_pbt` (Property 9), but
instead of a per-team mask driving success/failure, this double
deterministically responds with the configured invalid category for
every prompt.
"""
def __init__(self, *, category: str, rationale: str) -> None:
self._payload = json.dumps(
{"category": category, "rationale": rationale}
)
async def chat_completion(self, system: str, user: str) -> str:
return self._payload
class _StubDb:
"""Minimal ``DBClient`` double.
:meth:`LlmFulltextMatcher.match_teams` does not perform any database
lookups (the :class:`Team` already carries its competences and
references), so the stub does not need to implement any method
bodies. The constructor still requires a ``db`` argument, which is
why the stub exists.
"""
def _make_team() -> Team:
return Team(
team_id="42",
ouid="ou-42",
team_name="Team 42",
focus_name="Cloud",
about_us="",
offerings="",
interests="",
competences=[TeamCompetence(name="Python", top_competency=True)],
references=[
TeamReference(partner_name="Partner 42", projects="Project 42"),
],
)
def _make_task_profile() -> TaskProfile:
return TaskProfile(
id="t1",
title="Cloud Architect",
description="Design and implement cloud platforms.",
skills=["Python", "Kubernetes"],
)
# ---------------------------------------------------------------------------
# Property test
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(category=_invalid_category, rationale=_rationale)
def test_team_invalid_llm_category_routes_to_irrelevant(
category: str, rationale: str
) -> None:
"""Property 10: invalid LLM categories on the team path map to ``Irrelevant``.
For any invalid category string and any rationale the LLM returns,
the team matcher places the team exclusively in the ``Irrelevant``
bucket, preserves the (trimmed) original rationale text in the
stored rationale, appends the deterministic invalid-category hint,
and does not produce an error entry.
"""
matcher = LlmFulltextMatcher(
db=_StubDb(), # type: ignore[arg-type]
client=_StubLlm(category=category, rationale=rationale), # type: ignore[arg-type]
)
team = _make_team()
task_profile = _make_task_profile()
result = asyncio.run(
matcher.match_teams(
task_profile=task_profile,
teams=[team],
)
)
# All five canonical buckets are present in by_category.
assert set(result.by_category.keys()) == {
"Top",
"Good",
"Partial",
"Low",
"Irrelevant",
}
# The team is routed exclusively to ``Irrelevant``.
assert result.by_category["Top"] == []
assert result.by_category["Good"] == []
assert result.by_category["Partial"] == []
assert result.by_category["Low"] == []
assert len(result.by_category["Irrelevant"]) == 1
item = result.by_category["Irrelevant"][0]
assert item.item_id == team.team_id
assert item.category == "Irrelevant"
# The rationale carries the deterministic invalid-category hint.
assert "ungültige LLM-Kategorie" in item.rationale
assert "[Hinweis: ungültige LLM-Kategorie:" in item.rationale
# The original rationale text is preserved (after trimming, since the
# matcher applies ``.strip()`` to the combined rationale).
trimmed = rationale.strip()
if trimmed:
assert trimmed in item.rationale
# No error entries are produced for invalid categories: the team is a
# regular (re-routed) result, not a failure.
assert result.errors == []
@@ -0,0 +1,415 @@
# Feature: team-profile-matching, Property 3: Stammdaten-Konsistenz und INNER-JOIN-Filter
"""Property-based test for team master-data consistency and the
INNER JOIN filter implemented in
``TrinoClient._fetch_teams_base`` / ``get_all_teams`` /
``get_team_by_id``.
Validates Property 3 from the team-profile-matching design:
*Für jede* Stub-DB-Antwort (Listen von Teams- und OrganizationalUnits-
Zeilen mit beliebigen NULL/Empty-Verteilungen) gilt:
``get_all_teams()``
1. enthält ausschließlich Teams, deren ``team_id`` in der
OrganizationalUnits-Liste über ``id`` einen Treffer hat
(Anforderung 2.3),
2. liefert für jede der Spalten ``about_us``, ``offerings``,
``interests``, ``focus_name`` einen leeren String, wenn die Quelle
``NULL`` oder leer ist, und niemals ``None`` (Anforderung 2.4),
3. setzt ``Team.team_name`` auf den Namen aus der OU-Zeile, der dem
``team_id``-Match entspricht (Anforderung 2.2),
4. liefert für jede gefundene ``team_id`` ein konsistentes Ergebnis
mit ``get_team_by_id(team_id)`` (Anforderung 2.5).
The test does *not* exercise the real Trino driver. Instead it
installs a stub-DB-driven ``_FakeCursor`` that simulates the SQL
operations the production code expresses (INNER JOIN over the Teams
and OUs row lists, plus ``COALESCE`` for nullable text fields). The
cursor inspects the executed SQL string to dispatch between the
team-master-data query (with or without ``WHERE`` clause) and the
batch-competences / batch-references queries. The latter return
empty rows in this test scenario because Property 3 only constrains
the team master-data path (competences and references are covered
by Properties 4 and 5).
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
"""
from __future__ import annotations
from typing import Any
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.database.trino_client import TrinoClient
from teamlandkarte_mcp.models import Team
# ---------------------------------------------------------------------------
# Stub-DB row containers
# ---------------------------------------------------------------------------
class _StubDB:
"""A pair of stub row lists feeding the fake cursor.
``team_rows`` are dicts with keys ``team_id``, ``ouid``,
``focus_name``, ``about_us``, ``offerings``, ``interests`` (the
text columns may be ``None`` or empty strings to exercise the
``COALESCE`` paths).
``ou_rows`` are dicts with keys ``id`` and ``name`` modelling
rows of ``teamlandkarte_v_teammeter_organizational_units_latest``.
"""
def __init__(
self,
team_rows: list[dict[str, Any]],
ou_rows: list[dict[str, Any]],
) -> None:
self.team_rows = team_rows
self.ou_rows = ou_rows
def _coalesce_empty(value: Any) -> str:
"""Simulate SQL ``COALESCE(<col>, '')`` for nullable text columns."""
return "" if value is None else str(value)
def _simulate_teams_join(
stub: _StubDB,
*,
where_params: tuple[str, ...] | None,
) -> list[tuple]:
"""Simulate the INNER JOIN of ``_fetch_teams_base``.
Mirrors the production SQL:
- INNER JOIN ``teams_latest`` × ``organizational_units_latest`` on
``CAST(team_id AS VARCHAR) = CAST(id AS VARCHAR)`` (teams
without a matching OU are dropped).
- ``COALESCE(<text>, '')`` for ``focus_name``, ``about_us``,
``offerings``, ``interests`` (empty string for ``NULL``).
- Optional ``WHERE CAST(t.team_id AS VARCHAR) = ? OR t.ouid = ?``
filter when
``where_params`` is non-``None``.
- ``ORDER BY ou.name ASC, t.team_id ASC``.
Returns rows in the shape ``_fetch_teams_base`` consumes:
``(team_id, ouid, team_name, focus_name, about_us, offerings,
interests)``.
"""
ou_by_id = {str(ou["id"]): ou for ou in stub.ou_rows}
rows: list[tuple] = []
for team in stub.team_rows:
ou = ou_by_id.get(str(team["team_id"]))
if ou is None:
# INNER JOIN excludes teams without a matching OU.
continue
if where_params is not None:
tid_param, ouid_param = where_params
if (
str(team["team_id"]) != tid_param
and team["ouid"] != ouid_param
):
continue
rows.append(
(
team["team_id"],
team["ouid"],
ou["name"],
_coalesce_empty(team.get("focus_name")),
_coalesce_empty(team.get("about_us")),
_coalesce_empty(team.get("offerings")),
_coalesce_empty(team.get("interests")),
)
)
rows.sort(key=lambda r: (r[2], r[0]))
return rows
# ---------------------------------------------------------------------------
# Fake cursor / connection plumbing
# ---------------------------------------------------------------------------
class _FakeCursor:
"""Stub cursor that dispatches by SQL substring match.
For the team-master-data query (matched via the
``teamlandkarte_v_teams_latest`` table reference) the cursor
simulates the INNER JOIN and ``COALESCE`` semantics via
:func:`_simulate_teams_join`. For the batch-competences and
batch-references queries it returns an empty result set: those
code paths are exercised by Properties 4 and 5 and only need to
not break here.
"""
def __init__(self, stub: _StubDB) -> None:
self.stub = stub
self._next_rows: list[tuple] = []
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
params_tuple: tuple[str, ...]
if params is None:
params_tuple = ()
elif isinstance(params, tuple):
params_tuple = tuple(str(p) for p in params)
else:
params_tuple = tuple(str(p) for p in params)
if "teamlandkarte_v_teams_latest" in sql:
where_params: tuple[str, ...] | None
if "CAST(t.team_id AS VARCHAR) = ? OR t.ouid = ?" in sql:
# ``get_team_by_id`` passes the same id as both params.
if len(params_tuple) >= 2:
where_params = (params_tuple[0], params_tuple[1])
else:
where_params = ("", "")
else:
where_params = None
self._next_rows = _simulate_teams_join(
self.stub, where_params=where_params
)
return
if (
"teamlandkarte_v_teammeter_team_competences_latest" in sql
or "teamlandkarte_v_team_references_latest" in sql
):
# No competences / references rows in this scenario.
self._next_rows = []
return
# Defensive default: no rows for unrecognised statements.
self._next_rows = []
def fetchone(self) -> Any: # noqa: ANN401
return self._next_rows[0] if self._next_rows else None
def fetchall(self) -> list[tuple]:
return list(self._next_rows)
class _FakeCursorContext:
"""Context-manager wrapper around :class:`_FakeCursor`."""
def __init__(self, cursor: _FakeCursor) -> None:
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _make_client(stub: _StubDB) -> TrinoClient:
"""Build a ``TrinoClient`` whose ``_cursor`` yields a fake cursor."""
cursor = _FakeCursor(stub)
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
client._cursor = lambda: _FakeCursorContext(cursor) # type: ignore[method-assign]
return client
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Text-field generator: ``None`` (NULL), ``""`` (empty), or a non-empty
# printable string. Mirrors realistic data-lake content for the four
# nullable text columns.
_text_or_null = st.one_of(
st.none(),
st.just(""),
st.text(min_size=1, max_size=20),
)
@st.composite
def _stub_db(draw: st.DrawFn) -> _StubDB:
"""Build a stub DB with intentionally varied INNER JOIN coverage.
The strategy generates a small pool of OU rows (with non-empty,
non-conflicting names so the SQL ``ORDER BY`` is well-defined)
and a list of team rows whose ``team_id`` either references an
existing OU id or a deliberately non-matching id (to exercise
the INNER JOIN filter, Anforderung 2.3).
Team ``team_id`` values are kept unique within a single stub DB
so ``get_team_by_id(team_id)`` is unambiguous (Anforderung 2.5).
Team ``ouid`` values are likewise unique. Text columns are drawn
from :data:`_text_or_null` to cover both ``NULL`` and ``""``
branches (Anforderung 2.4).
"""
# 1) Generate OU rows with unique ids and unique non-empty names.
n_ous = draw(st.integers(min_value=0, max_value=4))
ou_names = draw(
st.lists(
st.text(
alphabet=st.characters(
whitelist_categories=("Lu", "Ll", "Nd")
),
min_size=1,
max_size=8,
),
min_size=n_ous,
max_size=n_ous,
unique=True,
)
)
ou_rows = [
{"id": f"OU{i}", "name": name}
for i, name in enumerate(ou_names)
]
# 2) Generate team rows. Each team independently picks an existing
# OU id (matching team_id) or a deliberately missing id.
n_teams = draw(st.integers(min_value=0, max_value=6))
teams: list[dict[str, Any]] = []
used_team_ids: set[str] = set()
for i in range(n_teams):
prefer_match = draw(st.booleans()) and ou_rows
if prefer_match:
candidate = draw(
st.sampled_from([ou["id"] for ou in ou_rows])
)
else:
candidate = f"MISS{i}"
if candidate in used_team_ids:
# Skip duplicates so ``get_team_by_id`` is unambiguous.
continue
used_team_ids.add(candidate)
teams.append(
{
"team_id": candidate,
"ouid": f"TEAM_OU_{i}",
"focus_name": draw(_text_or_null),
"about_us": draw(_text_or_null),
"offerings": draw(_text_or_null),
"interests": draw(_text_or_null),
}
)
return _StubDB(team_rows=teams, ou_rows=ou_rows)
# ---------------------------------------------------------------------------
# Property
# ---------------------------------------------------------------------------
_SETTINGS = settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@_SETTINGS
@given(stub=_stub_db())
def test_team_master_data_consistency_and_inner_join(
stub: _StubDB,
) -> None:
"""Property 3: Stammdaten-Konsistenz und INNER-JOIN-Filter.
Asserts the four sub-properties listed in the module docstring
against the stub-DB-driven fake cursor.
"""
client = _make_client(stub)
teams = client.get_all_teams()
# ---------------------------------------------------------------
# (1) INNER JOIN filter: only teams with a matching OU id
# (Anforderung 2.3).
# ---------------------------------------------------------------
ou_by_id = {ou["id"]: ou for ou in stub.ou_rows}
expected_team_ids = {
t["team_id"] for t in stub.team_rows if t["team_id"] in ou_by_id
}
actual_team_ids = {t.team_id for t in teams}
assert actual_team_ids == expected_team_ids, (
"INNER JOIN filter mismatch: expected "
f"{expected_team_ids!r}, got {actual_team_ids!r}"
)
# ---------------------------------------------------------------
# (2) NULL/empty → ""; never ``None`` (Anforderung 2.4).
# ---------------------------------------------------------------
for team in teams:
assert isinstance(team, Team)
for field_name in (
"focus_name",
"about_us",
"offerings",
"interests",
):
value = getattr(team, field_name)
assert isinstance(value, str), (
f"{field_name} must be str, got "
f"{type(value).__name__}: {value!r}"
)
# ---------------------------------------------------------------
# (3) team_name must come from the matched OU row
# (Anforderung 2.2).
# ---------------------------------------------------------------
for team in teams:
ou = ou_by_id[team.team_id]
assert team.team_name == ou["name"], (
f"team_name mismatch for {team.team_id!r}: "
f"expected {ou['name']!r}, got {team.team_name!r}"
)
# ---------------------------------------------------------------
# (4) ``get_team_by_id`` is consistent with ``get_all_teams``
# (Anforderung 2.5).
# ---------------------------------------------------------------
by_team_id = {t.team_id: t for t in teams}
for team_id, expected_team in by_team_id.items():
single = client.get_team_by_id(team_id)
assert single is not None, (
f"get_team_by_id({team_id!r}) returned None despite the "
f"team appearing in get_all_teams()"
)
assert single.team_id == expected_team.team_id
assert single.ouid == expected_team.ouid
assert single.team_name == expected_team.team_name
assert single.focus_name == expected_team.focus_name
assert single.about_us == expected_team.about_us
assert single.offerings == expected_team.offerings
assert single.interests == expected_team.interests
# And: a team_id that the INNER JOIN excluded must not be
# retrievable via ``get_team_by_id`` either.
excluded = [
t["team_id"]
for t in stub.team_rows
if t["team_id"] not in ou_by_id
]
for missing_id in excluded:
assert client.get_team_by_id(missing_id) is None, (
f"get_team_by_id({missing_id!r}) returned a team that "
f"was excluded by the INNER JOIN"
)
@@ -0,0 +1,251 @@
# Feature: team-profile-matching, Property 1: matching_method-Validierung in find_matching_teams
"""Property test for ``matching_method`` validation in ``find_matching_teams``.
The MCP tool ``find_matching_teams`` must reject any ``matching_method``
value whose ``strip().lower()`` representation is neither ``"score"``
nor ``"llm_fulltext"``. The rejection must:
1. Produce an error message that mentions both allowed values
(``score`` and ``llm_fulltext``).
2. Avoid touching the database (no team loading) and the LLM (no
``chat_completion`` call). The validation must short-circuit BEFORE
any side-effect.
The fake database and the fake ``chat_completion`` track call counts;
both counters must remain zero for every Hypothesis-generated invalid
input.
**Validates: Requirements 1.5, 1.6**
"""
from __future__ import annotations
from typing import Any
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
class _CountingDB:
"""Fake DB that counts how often the team loaders are invoked.
Any non-zero counter after a rejected validation is a bug: the
``find_matching_teams`` validation must short-circuit before the
DB is touched.
"""
def __init__(self) -> None:
self.team_calls = 0
self.team_by_id_calls = 0
# Capacity counters are kept for symmetry with the capacity
# equivalent test; the team validation path must never call them
# either, but they would otherwise be unused attributes.
self.capacity_calls = 0
self.capacity_by_id_calls = 0
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["X"]
def get_all_competence_names(self) -> list[str]:
return ["A"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
self.capacity_calls += 1
return []
def get_recent_free_capacities(self, limit: int = 20):
return []
def get_capacity_by_id(self, capacity_id): # pragma: no cover
self.capacity_by_id_calls += 1
return None
def get_all_teams(self):
self.team_calls += 1
return []
def get_team_by_id(self, team_id): # pragma: no cover
self.team_by_id_calls += 1
return None
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _build_test_server(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> tuple[Any, _CountingDB, dict[str, int]]:
"""Construct a server backed by counting DB and LLM doubles."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
fake_db = _CountingDB()
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
)
llm_calls = {"count": 0}
async def _fake_chat_completion(self, system, user): # noqa: ARG001
llm_calls["count"] += 1
return '{"category":"Top","rationale":"r"}'
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
_fake_chat_completion,
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
srv = build_server(str(cfg))
return srv, fake_db, llm_calls
# Reject anything that, after trimming and lower-casing, is one of the
# canonical values (those would be valid inputs and would not exercise
# the error path) or the empty string. The empty/whitespace-only string
# is treated by the server as "no value provided" (it then falls back
# to the configured default), so it is not part of the invalid-input
# space this property test targets. Hypothesis is otherwise free to
# generate any text.
def _is_invalid_method(value: str) -> bool:
norm = value.strip().lower()
if norm == "":
return False
return norm not in ("score", "llm_fulltext")
_INVALID_METHOD = st.text(max_size=20).filter(_is_invalid_method)
@settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@given(value=_INVALID_METHOD)
@pytest.mark.asyncio
async def test_invalid_matching_method_in_find_matching_teams_is_rejected_without_side_effects(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
value: str,
) -> None:
"""Property 1: invalid matching_method values are rejected verbatim.
The error message must list both allowed values; the team DB loader
and the LLM mock must not be touched.
"""
srv, fake_db, llm_calls = _build_test_server(monkeypatch, tmp_path)
res = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": "X",
"competences": ["A"],
"matching_method": value,
},
)
# The error message references both allowed values.
assert "score" in res, (
f"missing 'score' in error response for {value!r}: {res!r}"
)
assert "llm_fulltext" in res, (
f"missing 'llm_fulltext' in error response for {value!r}: {res!r}"
)
# The response must signal an error (not a successful search).
lowered = res.lower()
assert ("error" in lowered) or ("invalid" in lowered), (
f"response is not an error for {value!r}: {res!r}"
)
# No DB/LLM access for the rejected validation path.
assert fake_db.team_calls == 0, (
f"DB team loader was invoked for invalid value {value!r}"
)
assert fake_db.team_by_id_calls == 0, (
f"DB get_team_by_id was invoked for invalid value {value!r}"
)
assert llm_calls["count"] == 0, (
f"LLM was invoked for invalid value {value!r}"
)
@@ -0,0 +1,328 @@
# Feature: team-profile-matching, Property 11: META und SearchCache markieren Team-Suchen korrekt
"""Property test for ``META`` and ``SearchCache`` markings of team searches.
For every successful ``find_matching_teams`` invocation the following
must hold:
1. The output contains a line ``SEARCH_ID=<uuid>`` where ``<uuid>``
parses as a valid UUID (Anforderung 8.1).
2. The output contains a line ``META=<json>`` whose JSON object has
``search_type == "team_search"`` and ``matching_method`` in
``{"score", "llm_fulltext"}`` (Anforderungen 1.4, 8.3).
3. The associated ``SearchCache`` entry carries
``results["search_type"] == "team_search"`` and
``results["matching_method"]`` equal to the ``META`` value
(Anforderung 8.2). This is verified indirectly via
``get_results_by_category`` whose own ``META`` line is sourced from
the cache entry's ``results`` dict.
4. ``get_results_by_category(search_id, ...)`` produces a table that
contains the team-specific headers ``Team Name``, ``Schwerpunkt``
and ``Top-Kompetenzen`` (Anforderung 8.4).
The test rebuilds the server per Hypothesis example with a counting
fake ``DBClient`` that returns a single team whose competences and
``focus_name`` overlap with the generated request, and an
``AsyncMock`` for ``AzureOpenAIClient.chat_completion`` that returns a
shared payload satisfying both the role-similarity (``similarity``)
and the LLM-fulltext (``category`` + ``rationale``) consumers.
**Validates: Requirements 1.4, 8.1, 8.2, 8.3, 8.4**
"""
from __future__ import annotations
import json
import uuid
from typing import Any
from unittest.mock import AsyncMock
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
import teamlandkarte_mcp.mcp_server as mcp_mod
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=60
max_size=100
[azure_openai]
endpoint='https://example.openai.azure.com'
api_version='2024-02-15-preview'
chat_deployment='gpt-4'
""".strip()
# Fixed pools so the generated request data overlaps with the team
# returned by the fake DB. Overlap guarantees that the score path
# produces non-trivial similarities and that the team lands in some
# category bucket; the property does not depend on which bucket it is
# (we read ``default_category`` from META).
_COMPETENCE_POOL = ("python", "java", "javascript", "fastapi")
_ROLE_POOL = ("Backend Developer", "Data Engineer", "Beliebige Rolle")
def _make_team() -> Team:
"""Single deterministic team containing the full competence pool."""
return Team(
team_id="t1",
ouid="ou-1",
team_name="Team Alpha",
focus_name="Backend Developer",
about_us="We build backends.",
offerings="APIs and services.",
interests="Distributed systems.",
competences=[
TeamCompetence(name="python", top_competency=True),
TeamCompetence(name="java", top_competency=False),
TeamCompetence(name="javascript", top_competency=False),
TeamCompetence(name="fastapi", top_competency=False),
],
references=[
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
],
)
class _FakeDB:
"""Minimal fake DB returning a single, fixed team."""
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Backend Developer"]
def get_all_competence_names(self) -> list[str]:
return list(_COMPETENCE_POOL)
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return []
def get_recent_free_capacities(self, limit: int = 20):
return []
def get_all_teams(self) -> list[Team]:
return [_make_team()]
def get_team_by_id(self, team_id): # pragma: no cover
team = _make_team()
if str(team_id) == team.team_id:
return team
return None
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _extract_search_id(output: str) -> str:
for line in output.splitlines():
if line.startswith("SEARCH_ID="):
return line[len("SEARCH_ID=") :].strip()
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
def _extract_meta(output: str) -> dict[str, Any]:
for line in output.splitlines():
if line.startswith("META="):
return json.loads(line[len("META=") :])
raise AssertionError(f"no META= line in tool output:\n{output}")
def _build_test_server(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Any:
"""Construct a server backed by ``_FakeDB`` and a mocked LLM."""
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
monkeypatch.setattr(
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
)
# A single payload satisfies both the score path
# (``similarity``) and the LLM-fulltext path (``category``,
# ``rationale``).
monkeypatch.setattr(
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
AsyncMock(
return_value=(
'{"similarity": 1.0, "category": "Top", "rationale": "ok"}'
)
),
)
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
return build_server(str(cfg))
# Hypothesis strategies for valid inputs. ``matching_method`` covers
# both supported values; we deliberately do not include the empty
# string here because that path resolves to the configured default and
# would make assertions on the META value match-dependent on config.
_VALID_METHODS = st.sampled_from(("score", "llm_fulltext"))
_ROLE_NAMES = st.sampled_from(_ROLE_POOL)
_COMPETENCES = st.lists(
st.sampled_from(_COMPETENCE_POOL),
min_size=1,
max_size=len(_COMPETENCE_POOL),
unique=True,
)
@settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@given(
role_name=_ROLE_NAMES,
competences=_COMPETENCES,
matching_method=_VALID_METHODS,
)
@pytest.mark.asyncio
async def test_team_search_meta_and_cache_markings_are_correct(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
role_name: str,
competences: list[str],
matching_method: str,
) -> None:
"""Property 11: META and SearchCache mark team searches correctly."""
srv = _build_test_server(monkeypatch, tmp_path)
# Run find_matching_teams with the generated valid inputs.
out = await _call_tool(
srv,
"find_matching_teams",
{
"role_name": role_name,
"competences": competences,
"matching_method": matching_method,
},
)
# (1) SEARCH_ID is present and parses as a UUID.
search_id = _extract_search_id(out)
parsed = uuid.UUID(search_id)
assert str(parsed) == search_id, (
f"SEARCH_ID is not a canonical UUID string: {search_id!r}"
)
# (2) META has search_type='team_search' and a valid matching_method.
meta = _extract_meta(out)
assert meta.get("search_type") == "team_search", (
f"expected search_type='team_search', got META={meta!r}"
)
assert meta.get("matching_method") in ("score", "llm_fulltext"), (
f"matching_method not in allowed set: META={meta!r}"
)
assert meta.get("matching_method") == matching_method, (
f"META matching_method {meta.get('matching_method')!r} does "
f"not match the request value {matching_method!r}"
)
assert meta.get("search_id") == search_id, (
f"META search_id {meta.get('search_id')!r} does not match "
f"SEARCH_ID line {search_id!r}"
)
# (3) The SearchCache entry must carry the same markings. We prove
# this indirectly via ``get_results_by_category`` whose META is
# sourced from ``entry.results``: if META has the right values,
# the persisted entry has them too.
default_category = str(meta.get("default_category") or "Top")
cat_out = await _call_tool(
srv,
"get_results_by_category",
{
"search_id": search_id,
"category": default_category,
"page": 1,
"page_size": 20,
},
)
cat_meta = _extract_meta(cat_out)
assert cat_meta.get("search_type") == "team_search", (
"SearchCache entry is missing search_type='team_search': "
f"META={cat_meta!r}"
)
assert cat_meta.get("matching_method") == matching_method, (
"SearchCache entry has wrong matching_method: "
f"expected {matching_method!r}, META={cat_meta!r}"
)
assert cat_meta.get("search_id") == search_id, (
f"category META search_id {cat_meta.get('search_id')!r} "
f"does not match SEARCH_ID {search_id!r}"
)
# (4) The team-specific table headers must be present in the
# rendered category table (regardless of whether the bucket is
# empty - the headers are emitted unconditionally).
for header in ("Team Name", "Schwerpunkt", "Top-Kompetenzen"):
assert header in cat_out, (
f"missing team header {header!r} in get_results_by_category "
f"output for matching_method={matching_method!r}, "
f"category={default_category!r}:\n{cat_out}"
)
@@ -0,0 +1,344 @@
# Feature: team-profile-matching, Property 6: Determinismus und Vollständigkeit der Team-Serialisierung
"""Property-based tests for deterministic team-profile serialization.
Covers Property 6 from the team-profile-matching design:
For every :class:`TeamProfile` instance ``p`` (including instances with all
mandatory fields empty, empty competence/reference lists, and references
with empty ``partner_name``) the helpers ``serialize_team_profile`` and
``build_team_profile`` from
``teamlandkarte_mcp.matching.profiles`` must satisfy:
1. ``serialize_team_profile(p) == serialize_team_profile(p)``
(determinism, requirement 13.1).
2. ``serialize_team_profile(build_team_profile(team)) ==
serialize_team_profile(build_team_profile(team))`` for every ``Team``
(idempotency of profile construction, requirement 13.2).
3. The output contains every one of the seven headings ``Teamname:``,
``Schwerpunkt:``, ``Über uns:``, ``Leistungen:``, ``Interessen:``,
``Kompetenzen:``, ``Referenzen:`` at least once and exactly in this
order (requirements 5.4, 5.7, 13.3).
4. For every competence with ``top_competency=True`` the output contains
the marker suffix ``(Top)`` directly attached to the competence name
(requirement 5.5).
5. For every reference with ``partner_name != ""`` the output contains
both the ``partner_name`` and the ``projects`` text as substrings
(requirement 5.6); for references with ``partner_name == ""`` the
corresponding line does **not** contain the ``Partner: `` token
(requirement 5.3).
6. The order of competence and reference list entries in the
serialization matches the order of ``profile.competences`` and
``profile.references`` (requirement 13.4).
**Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 13.1, 13.2, 13.3, 13.4**
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.matching.profiles import (
TeamCompetenceEntry,
TeamProfile,
TeamReferenceEntry,
build_team_profile,
serialize_team_profile,
)
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Small text strategy to keep generation fast and deterministic. Allows the
# empty string so the property covers the "all mandatory fields empty"
# case explicitly mentioned in the design.
_text = st.text(max_size=40)
# Competence names must be non-whitespace so substring assertions (e.g.
# ``f"{name} (Top)" in output``) are unambiguous.
_competence_name = st.text(min_size=1, max_size=30).filter(lambda s: s.strip() != "" and "\n" not in s)
# ``partner_name`` may be empty (NULL ``partner_id`` / Join mismatch) or
# any short non-empty text without newlines so substring checks remain
# unambiguous.
_partner_name = st.one_of(
st.just(""),
st.text(min_size=1, max_size=20).filter(lambda s: s.strip() != "" and "\n" not in s),
)
# The DB layer has filtered whitespace-only ``projects``, so every
# generated reference uses a non-empty, non-whitespace value. We also
# avoid newlines so per-reference line containment checks are robust.
_projects = st.text(min_size=1, max_size=40).filter(lambda s: s.strip() != "" and "\n" not in s)
_team_competence_entry = st.builds(
TeamCompetenceEntry,
name=_competence_name,
top_competency=st.booleans(),
)
_team_reference_entry = st.builds(
TeamReferenceEntry,
partner_name=_partner_name,
projects=_projects,
)
@st.composite
def _team_profile(draw: st.DrawFn) -> TeamProfile:
"""Generate a ``TeamProfile`` with mixed empty/non-empty fields and lists."""
return TeamProfile(
id=draw(_text),
ouid=draw(_text),
team_name=draw(_text),
focus_name=draw(_text),
about_us=draw(_text),
offerings=draw(_text),
interests=draw(_text),
competences=draw(st.lists(_team_competence_entry, max_size=5)),
references=draw(st.lists(_team_reference_entry, max_size=5)),
)
_team_competence_model = st.builds(
TeamCompetence,
name=_competence_name,
top_competency=st.booleans(),
)
_team_reference_model = st.builds(
TeamReference,
partner_name=_partner_name,
projects=_projects,
)
@st.composite
def _team(draw: st.DrawFn) -> Team:
"""Generate a ``Team`` with mixed empty/non-empty fields and lists."""
return Team(
team_id=draw(_text),
ouid=draw(_text),
team_name=draw(_text),
focus_name=draw(_text),
about_us=draw(_text),
offerings=draw(_text),
interests=draw(_text),
competences=draw(st.lists(_team_competence_model, max_size=5)),
references=draw(st.lists(_team_reference_model, max_size=5)),
)
_HEADINGS = (
"Teamname:",
"Schwerpunkt:",
"Über uns:",
"Leistungen:",
"Interessen:",
"Kompetenzen:",
"Referenzen:",
)
# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(profile=_team_profile())
def test_serialize_team_profile_is_deterministic(profile: TeamProfile) -> None:
"""Property 6.1: ``serialize_team_profile`` is deterministic.
Two consecutive calls on the same profile must yield byte-identical
strings (requirement 13.1).
"""
first = serialize_team_profile(profile)
second = serialize_team_profile(profile)
assert first == second, "serialize_team_profile is not deterministic"
@settings(max_examples=100, deadline=None)
@given(team=_team())
def test_serialize_team_profile_is_idempotent_via_build(team: Team) -> None:
"""Property 6.2: ``build_team_profile`` is idempotent under serialization.
For every ``Team``, building a profile and serializing it must yield
the exact same string on repeated invocations (requirement 13.2).
"""
first = serialize_team_profile(build_team_profile(team))
second = serialize_team_profile(build_team_profile(team))
assert first == second, "serialize_team_profile(build_team_profile(team)) is not idempotent"
@settings(max_examples=100, deadline=None)
@given(profile=_team_profile())
def test_serialize_team_profile_contains_all_headings_in_order(
profile: TeamProfile,
) -> None:
"""Property 6.3: Every heading appears at least once and in fixed order.
The serialized output must contain each of the seven headings
``Teamname:``, ``Schwerpunkt:``, ``Über uns:``, ``Leistungen:``,
``Interessen:``, ``Kompetenzen:``, ``Referenzen:`` and these
headings must appear strictly in this order
(requirements 5.4, 5.7, 13.3).
"""
output = serialize_team_profile(profile)
# Headings are emitted by the serializer at the start of a line.
# We require each heading to occur as a line prefix and search
# strictly past the previously-found heading line so user-provided
# text containing the same heading substring (e.g.
# ``interests='Referenzen:'``) cannot perturb the ordering check.
lines = output.split("\n")
last_line_index = -1
for heading in _HEADINGS:
found_line = -1
for i in range(last_line_index + 1, len(lines)):
if lines[i].startswith(heading):
found_line = i
break
assert found_line != -1, (
f"Heading {heading!r} missing at or after line "
f"{last_line_index + 1} in team serialization: "
f"{output!r}"
)
last_line_index = found_line
@settings(max_examples=100, deadline=None)
@given(profile=_team_profile())
def test_serialize_team_profile_marks_top_competences(
profile: TeamProfile,
) -> None:
"""Property 6.4: Top competences are marked with ``(Top)``.
For every competence with ``top_competency=True`` the serialized
output must contain the substring ``"<name> (Top)"`` (requirement
5.5).
"""
output = serialize_team_profile(profile)
for competence in profile.competences:
if competence.top_competency:
marker = f"{competence.name} (Top)"
assert marker in output, (
f"Top-competence marker {marker!r} missing from serialization: {output!r}"
)
@settings(max_examples=100, deadline=None)
@given(profile=_team_profile())
def test_serialize_team_profile_partner_handling(
profile: TeamProfile,
) -> None:
"""Property 6.5: Partner-name visibility and absence handling.
For every reference with non-empty ``partner_name`` the serialized
output contains both the partner name and the projects text as a
substring (requirement 5.6). For every reference with empty
``partner_name`` the corresponding line does not contain the
``"Partner: "`` token (requirement 5.3).
"""
output = serialize_team_profile(profile)
# Locate the references block; entries are emitted as ``- <line>``
# bullets directly after the ``Referenzen:`` heading line. The
# serializer separates lines exclusively with ``\n``; ``str.splitlines``
# would also split on Unicode separators (e.g. U+0085) that may legally
# appear inside ``projects``, so we deliberately use ``split("\n")``.
# ``Referenzen:`` is always preceded by ``Interessen: ...\n`` in the
# serializer's fixed field order, so the leading-newline anchor is
# guaranteed to match exactly the heading line (and not a heading-like
# substring inside one of the text fields above).
refs_anchor = output.find("\nReferenzen:")
assert refs_anchor != -1, f"References heading missing from serialization: {output!r}"
refs_block = output[refs_anchor + 1 :]
if not profile.references:
# No references - the block is rendered as ``Referenzen: (keine)``
# and must not contain any partner token.
assert "Partner: " not in refs_block
return
# Split into per-reference bullet lines (skip the heading line itself).
bullet_lines = [line[2:] for line in refs_block.split("\n")[1:] if line.startswith("- ")]
assert len(bullet_lines) == len(profile.references), (
f"Expected {len(profile.references)} reference bullet lines, got {len(bullet_lines)} in: {output!r}"
)
for line, ref in zip(bullet_lines, profile.references):
if ref.partner_name:
assert ref.partner_name in line, f"Partner name {ref.partner_name!r} missing from line {line!r}"
assert ref.projects.strip() in line, f"Projects text {ref.projects!r} missing from line {line!r}"
else:
assert "Partner: " not in line, (
f"Empty-partner reference line must not contain 'Partner: ' token: {line!r}"
)
@settings(max_examples=100, deadline=None)
@given(profile=_team_profile())
def test_serialize_team_profile_preserves_list_order(
profile: TeamProfile,
) -> None:
"""Property 6.6: List entry order matches the input order.
The order of competence and reference entries in the serialized
output matches the order in ``profile.competences`` and
``profile.references`` exactly (requirement 13.4).
"""
output = serialize_team_profile(profile)
# Verify competence ordering by scanning the competence block only;
# text fields above (e.g. ``team_name``) might coincidentally contain
# a ``- <name>`` substring, so we restrict the search window.
if profile.competences:
comp_start = output.find("\nKompetenzen:")
# Defensive: ``Kompetenzen:`` is always preceded by other lines
# (Teamname: etc.), so ``\nKompetenzen:`` must be present.
assert comp_start != -1
# Competence block ends just before the ``Referenzen:`` heading.
refs_start = output.find("\nReferenzen:", comp_start)
comp_block = output[comp_start:refs_start] if refs_start != -1 else output[comp_start:]
last_idx = -1
for competence in profile.competences:
# Use the bullet-prefixed form to avoid spurious matches in
# other fields (e.g. team_name).
needle = f"- {competence.name}"
idx = comp_block.find(needle, last_idx + 1)
assert idx > last_idx, (
f"Competence {competence.name!r} not found in expected order in serialization: {output!r}"
)
last_idx = idx
# Verify reference ordering by locating each ``projects`` text in
# turn within the references block (it is unique-enough because
# every reference's projects value is non-whitespace).
if profile.references:
refs_index = output.find("\nReferenzen:")
assert refs_index != -1
refs_block = output[refs_index:]
last_idx = -1
for reference in profile.references:
needle = reference.projects.strip()
idx = refs_block.find(needle, last_idx + 1)
assert idx > last_idx, (
f"Reference projects {reference.projects!r} not found in "
f"expected order in serialization: {output!r}"
)
last_idx = idx
@@ -0,0 +1,361 @@
# Feature: team-profile-matching, Task 5.5: Unit tests for build_team_profile / serialize_team_profile
"""Example-based unit tests for the team-profile builder and serializer.
These tests complement the property-based tests in
``tests/test_team_profile_serialization_pbt.py`` by pinning down concrete
example outputs for ``build_team_profile`` and ``serialize_team_profile``
defined in ``teamlandkarte_mcp.matching.profiles``.
Covered scenarios (see task 5.5 in the team-profile-matching spec):
* Empty strings on every text field plus empty competence/reference lists
still produce an output that contains all seven fixed German headings,
with empty values rendered after the heading and lists rendered as
``Kompetenzen: (keine)`` / ``Referenzen: (keine)``.
* Mixed top / non-top competences are rendered with the ``(Top)`` suffix
only on the top entries.
* References with a non-empty ``partner_name`` use the
``Partner: <name> Projekte: <projects>`` form (en-dash U+2013).
* References with an empty ``partner_name`` use the bare
``Projekte: <projects>`` form (no ``Partner:`` token, no placeholder).
* ``build_team_profile`` performs a 1:1 mapping from ``Team`` to
``TeamProfile`` without re-sorting, including the ouid, team_id and the
competence / reference lists.
* Empty competence / reference lists pass through ``build_team_profile``
unchanged.
**Validates: Requirements 5.1, 5.3, 5.4, 5.5, 5.6**
"""
from __future__ import annotations
from teamlandkarte_mcp.matching.profiles import (
TeamCompetenceEntry,
TeamProfile,
TeamReferenceEntry,
build_team_profile,
serialize_team_profile,
)
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
# ---------------------------------------------------------------------------
# build_team_profile
# ---------------------------------------------------------------------------
def test_build_team_profile_maps_all_fields_one_to_one() -> None:
"""``build_team_profile`` copies every ``Team`` field onto the profile.
Validates requirement 5.1: every team field (team_id, ouid, team_name,
focus_name, about_us, offerings, interests, competences, references)
appears on the resulting :class:`TeamProfile` with the exact same
value, in the exact same list order. ``team_id`` is converted to
``str`` for the profile ``id`` field.
"""
team = Team(
team_id="T-42",
ouid="ou-9",
team_name="Alpha Team",
focus_name="Backend",
about_us="Wir bauen APIs.",
offerings="Beratung",
interests="Cloud",
competences=[
TeamCompetence(name="Python", top_competency=True),
TeamCompetence(name="FastAPI", top_competency=False),
],
references=[
TeamReference(partner_name="Acme", projects="Project X"),
TeamReference(partner_name="", projects="Project Y"),
],
)
profile = build_team_profile(team)
assert profile.id == "T-42"
assert profile.ouid == "ou-9"
assert profile.team_name == "Alpha Team"
assert profile.focus_name == "Backend"
assert profile.about_us == "Wir bauen APIs."
assert profile.offerings == "Beratung"
assert profile.interests == "Cloud"
# Competences are preserved 1:1 and in the original order.
assert profile.competences == [
TeamCompetenceEntry(name="Python", top_competency=True),
TeamCompetenceEntry(name="FastAPI", top_competency=False),
]
# References are preserved 1:1 and in the original order.
assert profile.references == [
TeamReferenceEntry(partner_name="Acme", projects="Project X"),
TeamReferenceEntry(partner_name="", projects="Project Y"),
]
def test_build_team_profile_passes_empty_lists_through() -> None:
"""Empty ``competences`` and ``references`` lists pass through unchanged.
Validates requirement 5.1: an empty list on the input team yields an
empty list on the resulting profile, without dropping the profile or
inserting placeholder entries.
"""
team = Team(
team_id="T-empty",
ouid="ou-empty",
team_name="",
focus_name="",
about_us="",
offerings="",
interests="",
competences=[],
references=[],
)
profile = build_team_profile(team)
assert profile.competences == []
assert profile.references == []
# The string fields stay empty without any placeholder.
assert profile.team_name == ""
assert profile.focus_name == ""
assert profile.about_us == ""
assert profile.offerings == ""
assert profile.interests == ""
def test_build_team_profile_does_not_reorder_competences() -> None:
"""``build_team_profile`` preserves the input order of competences.
The DB layer is the single source of truth for the deterministic
competence order ``(top_competency desc, name asc)``; the builder
must not re-sort. Validates requirement 5.1 / 13.4.
"""
team = Team(
team_id="T-1",
ouid="ou-1",
team_name="Team",
focus_name="",
about_us="",
offerings="",
interests="",
competences=[
# Intentionally not in (top desc, name asc) order so a
# re-sort would be visible.
TeamCompetence(name="Zeta", top_competency=False),
TeamCompetence(name="Alpha", top_competency=True),
TeamCompetence(name="Beta", top_competency=False),
],
references=[],
)
profile = build_team_profile(team)
assert [c.name for c in profile.competences] == ["Zeta", "Alpha", "Beta"]
assert [c.top_competency for c in profile.competences] == [False, True, False]
# ---------------------------------------------------------------------------
# serialize_team_profile
# ---------------------------------------------------------------------------
def test_serialize_team_profile_empty_profile_emits_all_headings() -> None:
"""An empty profile still emits the seven fixed German headings.
Validates requirements 5.4 (fixed heading order) and the ``(keine)``
fallback for empty competence and reference lists.
"""
profile = TeamProfile(
id="",
ouid="",
team_name="",
focus_name="",
about_us="",
offerings="",
interests="",
competences=[],
references=[],
)
output = serialize_team_profile(profile)
expected = "\n".join(
[
"Teamname: ",
"Schwerpunkt: ",
"Über uns: ",
"Leistungen: ",
"Interessen: ",
"Kompetenzen: (keine)",
"Referenzen: (keine)",
]
)
assert output == expected
def test_serialize_team_profile_mixed_top_and_non_top_competences() -> None:
"""Top competences carry the ``(Top)`` suffix; non-top entries do not.
Validates requirement 5.5: the marker is rendered as ``" (Top)"``
directly after the competence name, exactly for entries with
``top_competency=True``.
"""
profile = TeamProfile(
id="T-1",
ouid="ou-1",
team_name="Team Alpha",
focus_name="Backend",
about_us="",
offerings="",
interests="",
competences=[
TeamCompetenceEntry(name="Python", top_competency=True),
TeamCompetenceEntry(name="FastAPI", top_competency=False),
TeamCompetenceEntry(name="SQL", top_competency=True),
],
references=[],
)
output = serialize_team_profile(profile)
assert "Kompetenzen:\n- Python (Top)\n- FastAPI\n- SQL (Top)" in output
# Non-top entries must not accidentally pick up the marker.
assert "FastAPI (Top)" not in output
def test_serialize_team_profile_reference_with_partner_name() -> None:
"""References with a partner render as ``Partner: <name> Projekte: <projects>``.
Validates requirement 5.6: both partner name and projects text are
visible, joined with the en-dash U+2013 (``\u2013``).
"""
profile = TeamProfile(
id="T-1",
ouid="ou-1",
team_name="Team",
focus_name="",
about_us="",
offerings="",
interests="",
competences=[],
references=[
TeamReferenceEntry(partner_name="Acme", projects="Project X"),
],
)
output = serialize_team_profile(profile)
assert "Referenzen:\n- Partner: Acme \u2013 Projekte: Project X" in output
def test_serialize_team_profile_reference_without_partner_name() -> None:
"""References without a partner render as bare ``Projekte: <projects>``.
Validates requirement 5.3: an empty ``partner_name`` suppresses the
``Partner: `` token entirely, and no placeholder is inserted.
"""
profile = TeamProfile(
id="T-1",
ouid="ou-1",
team_name="Team",
focus_name="",
about_us="",
offerings="",
interests="",
competences=[],
references=[
TeamReferenceEntry(partner_name="", projects="Project Y"),
],
)
output = serialize_team_profile(profile)
# Locate the references block to make the absence-assertion robust
# against unrelated text in other fields.
refs_start = output.index("Referenzen:")
refs_block = output[refs_start:]
assert "- Projekte: Project Y" in refs_block
assert "Partner: " not in refs_block
# No en-dash filler when there is no partner.
assert "\u2013" not in refs_block
def test_serialize_team_profile_mixed_references_with_and_without_partner() -> None:
"""Mixed references render each entry with the appropriate shape.
Validates requirements 5.3 and 5.6 jointly: in a single profile, a
reference with a partner uses the ``Partner: ... Projekte: ...``
form, and a reference without a partner uses the bare
``Projekte: ...`` form. Order is preserved 1:1.
"""
profile = TeamProfile(
id="T-1",
ouid="ou-1",
team_name="Team",
focus_name="",
about_us="",
offerings="",
interests="",
competences=[],
references=[
TeamReferenceEntry(partner_name="Acme", projects="Project X"),
TeamReferenceEntry(partner_name="", projects="Project Y"),
],
)
output = serialize_team_profile(profile)
expected_block = (
"Referenzen:\n"
"- Partner: Acme \u2013 Projekte: Project X\n"
"- Projekte: Project Y"
)
assert expected_block in output
def test_serialize_team_profile_full_example_round_trip() -> None:
"""End-to-end example: build then serialize a populated team.
Validates requirements 5.1, 5.3, 5.4, 5.5, 5.6 together: the full
serialized output for a representative team matches the expected
fixed-format string exactly, including heading order, ``(Top)``
markers, and reference shapes.
"""
team = Team(
team_id="T-42",
ouid="ou-9",
team_name="Alpha Team",
focus_name="Backend",
about_us="Wir bauen APIs.",
offerings="Beratung",
interests="Cloud",
competences=[
TeamCompetence(name="Python", top_competency=True),
TeamCompetence(name="FastAPI", top_competency=False),
],
references=[
TeamReference(partner_name="Acme", projects="Project X"),
TeamReference(partner_name="", projects="Project Y"),
],
)
output = serialize_team_profile(build_team_profile(team))
expected = "\n".join(
[
"Teamname: Alpha Team",
"Schwerpunkt: Backend",
"Über uns: Wir bauen APIs.",
"Leistungen: Beratung",
"Interessen: Cloud",
"Kompetenzen:",
"- Python (Top)",
"- FastAPI",
"Referenzen:",
"- Partner: Acme \u2013 Projekte: Project X",
"- Projekte: Project Y",
]
)
assert output == expected
@@ -0,0 +1,431 @@
# Feature: team-profile-matching, Property 5: Referenz-Batch ist konsistent zur Einzel-Variante
"""Property-based test for the batch/single consistency of the team
reference queries on ``TrinoClient``.
Validates Property 5 from the team-profile-matching design:
*Für jede* Liste von OUIDs ``ouids`` und jede Stub-DB-Antwort gilt:
``batch_get_team_references(ouids)[ouid]`` ist für jedes ``ouid`` in
``ouids`` gleich ``get_team_references(ouid)``. Außerdem gilt für
jeden Eintrag der Ergebnislisten:
- bei ``partner_id IS NULL`` oder leerem Partner-Join ist
``partner_name == ""``, der Eintrag bleibt aber in der Liste
erhalten (Anforderung 4.5),
- Einträge mit leerem oder ausschließlich Whitespace gefülltem
``projects`` sind nicht enthalten (Anforderung 4.6),
- die Reihenfolge ist ``(partner_name asc, projects asc)`` und damit
deterministisch (Anforderung 4.7),
- für ``ouid``-Werte ohne Referenzen ist die Liste leer
(Anforderung 4.4).
Zusätzlich ruft die Batch-Methode genau einen ``cur.execute(...)``-Call
gegen ``teamlandkarte_v_team_references_latest`` ab (Anforderung 4.3).
The test installs a stub cursor that simulates the SQL
``COALESCE(p.name, '')`` and ``ORDER BY`` semantics for both the
single (``WHERE r.ouid = ?``) and the batch (``WHERE r.ouid IN
(?, ?, ...)``) form. The simulated cursor holds an in-memory list of
reference rows ``(ouid, projects, partner_name)`` where ``projects``
may be ``None``, empty or whitespace-only (to exercise the
Python-side filter, Requirement 4.6) and ``partner_name`` may be
``None`` (to exercise the COALESCE normalisation, Requirement 4.5).
**Validates: Requirements 4.1, 4.3, 4.4, 4.5, 4.6, 4.7**
"""
from __future__ import annotations
from typing import Any
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.database.trino_client import TrinoClient
# ---------------------------------------------------------------------------
# Stub-DB row container
# ---------------------------------------------------------------------------
class _StubDB:
"""Holds the in-memory reference rows for the fake cursor.
Each row is a ``(ouid, projects, partner_name)`` tuple. ``projects``
may be ``None``, ``""`` or whitespace-only (all must be filtered
by the Trino client, Requirement 4.6) or any non-empty string.
``partner_name`` may be ``None`` (NULL after partner_id IS NULL or
no join match) and must be normalised to ``""`` (Requirement 4.5).
"""
def __init__(self, rows: list[tuple[str, Any, Any]]) -> None:
self.rows = rows
def _partner_norm(value: Any) -> str:
"""Mirror ``COALESCE(p.name, '')`` on the SQL side."""
if value is None:
return ""
return str(value)
def _projects_sort_key(projects: Any) -> tuple[int, str]:
"""Order keys with ``None`` last, then by string ascending.
Trino orders ``NULL`` last for ``ASC``; we mirror that here so the
simulated cursor produces a deterministic order even for the rows
that the Python-side filter will subsequently drop.
"""
if projects is None:
return (1, "")
return (0, str(projects))
# ---------------------------------------------------------------------------
# Fake cursor / connection plumbing
# ---------------------------------------------------------------------------
_REFERENCES_VIEW = "teamlandkarte_v_team_references_latest"
class _FakeCursor:
"""Stub cursor that dispatches by SQL substring match.
For the team-reference SQL the cursor inspects whether the
statement contains ``WHERE r.ouid = ?`` (single variant) or
``WHERE r.ouid IN`` (batch variant) and returns rows in the
column shape the production code expects.
Single variant column shape: ``(projects, partner_name)``.
Batch variant column shape: ``(ouid, projects, partner_name)``.
The cursor counts how often it has been asked to execute a
statement against the references view so the test can assert the
batch method issues exactly one such call (Requirement 4.3).
"""
def __init__(self, stub: _StubDB) -> None:
self.stub = stub
self._next_rows: list[tuple] = []
self.references_executes = 0
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
if params is None:
params_tuple: tuple[str, ...] = ()
elif isinstance(params, tuple):
params_tuple = tuple(str(p) for p in params)
else:
params_tuple = tuple(str(p) for p in params)
if _REFERENCES_VIEW not in sql:
self._next_rows = []
return
self.references_executes += 1
if "WHERE r.ouid IN" in sql:
self._next_rows = self._simulate_batch(params_tuple)
return
if "WHERE r.ouid = ?" in sql:
self._next_rows = self._simulate_single(params_tuple)
return
# Defensive default: the production code only emits the two
# shapes above, so any other shape is unexpected.
self._next_rows = []
def _simulate_single(
self, params: tuple[str, ...]
) -> list[tuple]:
"""Simulate the single-OUID query.
Mirrors the SQL ``ORDER BY partner_name ASC, r.projects ASC``
and emits the two-column result shape used by
``get_team_references``. ``partner_name`` is COALESCE-d on
the SQL side, so the emitted shape never carries ``None``
for the partner column.
"""
target = params[0] if params else ""
rows = [r for r in self.stub.rows if r[0] == target]
rows.sort(
key=lambda r: (
_partner_norm(r[2]),
_projects_sort_key(r[1]),
)
)
return [(r[1], _partner_norm(r[2])) for r in rows]
def _simulate_batch(
self, params: tuple[str, ...]
) -> list[tuple]:
"""Simulate the batch-OUID query.
Mirrors the SQL ``ORDER BY r.ouid ASC, partner_name ASC,
r.projects ASC`` and emits the three-column result shape used
by ``batch_get_team_references``.
"""
wanted = set(params)
rows = [r for r in self.stub.rows if r[0] in wanted]
rows.sort(
key=lambda r: (
str(r[0]),
_partner_norm(r[2]),
_projects_sort_key(r[1]),
)
)
return [(r[0], r[1], _partner_norm(r[2])) for r in rows]
def fetchone(self) -> Any: # noqa: ANN401
return self._next_rows[0] if self._next_rows else None
def fetchall(self) -> list[tuple]:
return list(self._next_rows)
class _FakeCursorContext:
"""Context-manager wrapper around a single :class:`_FakeCursor`."""
def __init__(self, cursor: _FakeCursor) -> None:
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _make_client(stub: _StubDB) -> tuple[TrinoClient, _FakeCursor]:
"""Build a ``TrinoClient`` whose ``_cursor`` yields a fake cursor.
Returns the client together with the underlying fake cursor so
callers can inspect its ``references_executes`` counter.
"""
cursor = _FakeCursor(stub)
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
client._cursor = lambda: _FakeCursorContext(cursor) # type: ignore[method-assign]
return client, cursor
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# A small pool of distinct OUID strings keeps the search space tight and
# makes it likely that several rows share the same ouid (so grouping
# behaviour and ordering inside a group are exercised).
_KNOWN_OUIDS = ("OU1", "OU2", "OU3", "OU4")
_UNKNOWN_OUIDS = ("UNKNOWN1", "UNKNOWN2")
_BLANK_OUIDS = ("", " ")
# ``projects`` covers four shapes:
# - ``None`` (NULL projects -> must be filtered, Req 4.6)
# - ``""`` (empty -> must be filtered, Req 4.6)
# - ``" "`` (whitespace-only -> must be filtered, Req 4.6)
# - non-empty text (kept; trimmed and used for ordering)
_projects_value = st.one_of(
st.none(),
st.just(""),
st.just(" "),
st.text(
alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd")),
min_size=1,
max_size=4,
),
)
# ``partner_name`` covers three shapes:
# - ``None`` (partner_id IS NULL or join miss -> COALESCE to "", Req 4.5)
# - ``""`` (partner row has empty name -> kept as "")
# - ``"Acme"`` (concrete partner name)
_partner_value = st.one_of(
st.none(),
st.just(""),
st.just("Acme"),
)
@st.composite
def _stub_and_inputs(draw: st.DrawFn) -> tuple[_StubDB, list[str]]:
"""Generate stub reference rows and a list of input OUIDs.
The strategy intentionally mixes:
- rows for OUIDs in :data:`_KNOWN_OUIDS` (so batch/single both find
data),
- input OUIDs that resolve to known/unknown/blank values (so
Requirements 4.4 and the blank-input short-circuit both fire),
- ``None``/``""``/whitespace ``projects`` values (Requirement 4.6),
- ``None``/``""`` ``partner_name`` values (Requirement 4.5).
Duplicate input OUIDs are allowed: the production batch method
deduplicates via the output dict's keys, so the test asserts the
consistency property only over the set of distinct input OUIDs.
"""
rows = draw(
st.lists(
st.tuples(
st.sampled_from(_KNOWN_OUIDS),
_projects_value,
_partner_value,
),
min_size=0,
max_size=15,
)
)
candidates = list(_KNOWN_OUIDS) + list(_UNKNOWN_OUIDS) + list(_BLANK_OUIDS)
input_ouids = draw(
st.lists(
st.sampled_from(candidates),
min_size=0,
max_size=6,
)
)
return _StubDB(rows=rows), input_ouids
# ---------------------------------------------------------------------------
# Property
# ---------------------------------------------------------------------------
_SETTINGS = settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@_SETTINGS
@given(payload=_stub_and_inputs())
def test_batch_team_references_matches_single_variant(
payload: tuple[_StubDB, list[str]],
) -> None:
"""Property 5: Referenz-Batch ist konsistent zur Einzel-Variante.
Asserts the consistency, per-entry invariants and the
single-execute-call property listed in the module docstring
against the stub-DB-driven fake cursor.
"""
stub, input_ouids = payload
# -----------------------------------------------------------------
# (6) Batch-method-call-counter (Requirement 4.3): the batch method
# must issue exactly one cur.execute(...) against the references
# view, regardless of how many OUIDs are requested. This is checked
# on a dedicated client instance so the counter is not polluted by
# the per-OUID single-variant calls below.
# -----------------------------------------------------------------
batch_client, batch_cursor = _make_client(stub)
batch_for_count = batch_client.batch_get_team_references(input_ouids)
if input_ouids and any(str(o).strip() for o in input_ouids):
assert batch_cursor.references_executes == 1, (
"batch_get_team_references must issue exactly one execute "
"against the references view; got "
f"{batch_cursor.references_executes}"
)
else:
# All-blank or empty inputs short-circuit before issuing SQL.
assert batch_cursor.references_executes == 0, (
"batch_get_team_references must not issue SQL when all "
"OUIDs are blank/empty; got "
f"{batch_cursor.references_executes}"
)
# -----------------------------------------------------------------
# (1) Batch result has exactly the requested OUIDs as keys
# (Requirement 4.4: empty list for OUIDs without references).
# -----------------------------------------------------------------
expected_keys = {str(o) for o in input_ouids}
assert set(batch_for_count.keys()) == expected_keys, (
"batch_get_team_references must contain every input ouid; "
f"expected {expected_keys!r}, got {set(batch_for_count.keys())!r}"
)
# Use a fresh client for the per-OUID consistency comparison so
# the fake cursor's row buffer and execute counter are isolated
# from the batch call above.
consistency_client, _ = _make_client(stub)
for ouid in input_ouids:
ouid_key = str(ouid)
single = consistency_client.get_team_references(ouid)
batch_entry = batch_for_count[ouid_key]
# -------------------------------------------------------------
# (2) Batch-vs-single consistency (Requirements 4.1, 4.3).
# -------------------------------------------------------------
assert batch_entry == single, (
f"batch[{ouid_key!r}] differs from get_team_references"
f"({ouid!r}): batch={batch_entry!r}, single={single!r}"
)
# -------------------------------------------------------------
# (3, 4) Per-entry invariants (Requirements 4.5, 4.6).
# -------------------------------------------------------------
for entry in single:
assert isinstance(entry, dict)
assert set(entry.keys()) == {"partner_name", "projects"}
# 4.6: filtered out NULL/empty/whitespace-only projects.
assert isinstance(entry["projects"], str), (
"projects must be a string after filtering, got "
f"{entry['projects']!r}"
)
assert entry["projects"].strip() == entry["projects"], (
"projects must be stripped of surrounding whitespace, "
f"got {entry['projects']!r}"
)
assert entry["projects"], (
"entries with NULL, empty or whitespace-only projects "
f"must be filtered out, got {entry!r}"
)
# 4.5: partner_name is always a string (never None); may
# be empty when the SQL-side COALESCE produced "".
assert isinstance(entry["partner_name"], str), (
"partner_name must be a string after COALESCE, got "
f"{entry['partner_name']!r}"
)
# -------------------------------------------------------------
# (5) Order is (partner_name asc, projects asc) (Req 4.7).
# -------------------------------------------------------------
ordering = [(e["partner_name"], e["projects"]) for e in single]
assert ordering == sorted(ordering), (
"reference list is not ordered by (partner_name asc, "
f"projects asc) for ouid={ouid!r}: {single!r}"
)
# 4.4: blank/unknown OUIDs map to an empty list.
if not str(ouid).strip() or str(ouid) in _UNKNOWN_OUIDS:
assert batch_entry == [], (
"OUID without references must map to an empty list; "
f"ouid={ouid!r}, got {batch_entry!r}"
)
@@ -0,0 +1,250 @@
# Feature: team-profile-matching, Property 14: Schema-Verifikation deckt fehlende Spalten in den vier neuen Views auf
"""Property-based test for schema verification of the four new team views.
Validates Property 14 from the team-profile-matching design:
*Für jede* Mock-DB-Antwort, die in einer der vier Views
(``teamlandkarte_v_teams_latest``,
``teamlandkarte_v_teammeter_organizational_units_latest``,
``teamlandkarte_v_teammeter_team_competences_latest``,
``teamlandkarte_v_team_references_latest``) eine erwartete Spalte
weglässt, liefert ``verify_required_columns(...)`` für die in
``mcp_server.build_server`` konfigurierte ``schema_expected``-Erweiterung
einen ``SchemaIssue`` mit dem betroffenen Tabellennamen und enthält die
fehlende Spalte in ``message``. Wenn alle erwarteten Spalten vorhanden
sind, ist das Issue-Set für diese vier Views leer.
The Hypothesis strategy generates, for a randomly chosen view, a
non-empty subset of its expected columns to drop. The stub DB then
returns ``full_set - dropped`` for that view (and the full sets for the
other three views). The test asserts that ``verify_required_columns``:
1. Returns at least one ``SchemaIssue`` whose ``table`` equals the
chosen view.
2. The issue's ``message`` mentions every dropped column (lowercased,
per the verifier's normalization).
3. No issues are reported for the other three views (which received
their full column sets).
The positive case is covered by an additional non-property test that
feeds the full column sets and expects an empty issue list.
**Validates: Requirements 12.1, 12.2, 12.3, 12.4**
"""
from __future__ import annotations
from typing import Iterable
from hypothesis import given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.database.schema_verifier import (
SchemaIssue,
verify_required_columns,
)
# ---------------------------------------------------------------------------
# Source of truth: the four new views and their expected columns.
# Mirrors the ``schema_expected`` extension configured in
# ``mcp_server.build_server`` (see design.md, "Schema-Verifikation beim
# Start"). Keeping this map local keeps the test independent of
# ``build_server`` runtime wiring (which requires a full config + DB).
# ---------------------------------------------------------------------------
_EXPECTED_COLUMNS: dict[str, frozenset[str]] = {
"teamlandkarte_v_teams_latest": frozenset(
{"team_id", "ouid", "about_us", "offerings", "interests", "focus_name"}
),
"teamlandkarte_v_teammeter_organizational_units_latest": frozenset(
{"id", "name"}
),
"teamlandkarte_v_teammeter_team_competences_latest": frozenset(
{"ouid", "competence_id", "top_competency"}
),
"teamlandkarte_v_team_references_latest": frozenset(
{"ouid", "partner_id", "projects"}
),
}
_VIEW_NAMES: tuple[str, ...] = tuple(_EXPECTED_COLUMNS.keys())
# ---------------------------------------------------------------------------
# Test double
# ---------------------------------------------------------------------------
class _StubDB:
"""Stub DB exposing ``get_table_columns`` for a fixed mapping.
The verifier protocol only requires this single method (see
``schema_verifier.SchemaIntrospector``), so this stub is sufficient
to exercise the full code path without touching a real database.
"""
def __init__(self, mapping: dict[str, list[str]]):
self._mapping = mapping
def get_table_columns(self, table: str) -> list[str]:
# ``verify_required_columns`` treats unknown tables as "no columns".
return list(self._mapping.get(table, []))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _full_columns_for_all_views() -> dict[str, list[str]]:
"""Return the full column lists for every configured view."""
return {view: sorted(cols) for view, cols in _EXPECTED_COLUMNS.items()}
def _expected_map() -> dict[str, set[str]]:
"""Return a fresh ``expected`` map matching the verifier's signature.
The verifier expects ``dict[str, set[str]]``; the canonical map uses
``frozenset`` for immutability, so we materialize plain ``set``s here.
"""
return {view: set(cols) for view, cols in _EXPECTED_COLUMNS.items()}
def _issues_for_table(
issues: Iterable[SchemaIssue], table: str
) -> list[SchemaIssue]:
return [issue for issue in issues if issue.table == table]
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _drop_subset_strategy() -> st.SearchStrategy[tuple[str, frozenset[str]]]:
"""Strategy for ``(view, columns_to_drop)`` pairs.
Picks a view from the four configured views and a non-empty subset
of its expected columns to "drop" (i.e. omit from the stub DB's
response for that view). The subset is non-empty so the property's
precondition ("at least one expected column is omitted") always
holds.
"""
def _for_view(view: str) -> st.SearchStrategy[tuple[str, frozenset[str]]]:
cols = sorted(_EXPECTED_COLUMNS[view])
# Subset of size in [1, len(cols)]: covers the full powerset
# minus the empty set, which corresponds to the design's
# "Powerset ... minus a randomly chosen column".
return st.lists(
st.sampled_from(cols),
min_size=1,
max_size=len(cols),
unique=True,
).map(lambda picks: (view, frozenset(picks)))
return st.sampled_from(_VIEW_NAMES).flatmap(_for_view)
# ---------------------------------------------------------------------------
# Property: dropping any subset of columns yields an issue per dropped column
# ---------------------------------------------------------------------------
@settings(max_examples=100, deadline=None)
@given(case=_drop_subset_strategy())
def test_missing_columns_in_any_of_the_four_views_produces_schema_issue(
case: tuple[str, frozenset[str]],
) -> None:
"""Property 14 - negative direction.
For every (view, non-empty drop-subset), the verifier emits a
``SchemaIssue`` whose ``table`` is the view and whose ``message``
mentions every dropped column. Other views (with full column sets)
must not produce any issues.
"""
view, dropped = case
full_cols = _EXPECTED_COLUMNS[view]
remaining = sorted(full_cols - dropped)
db_mapping = _full_columns_for_all_views()
db_mapping[view] = remaining
db = _StubDB(db_mapping)
issues = verify_required_columns(db=db, expected=_expected_map())
# 1) The affected view is reported exactly once.
affected = _issues_for_table(issues, view)
assert len(affected) == 1, (
f"Expected exactly one SchemaIssue for {view!r}, got {affected!r}"
)
# 2) Each dropped column (normalized to lowercase, like the verifier
# does internally) appears in the issue message.
message = affected[0].message
assert message.startswith("missing columns: "), message
for col in dropped:
assert col.lower() in message, (
f"Dropped column {col!r} not mentioned in issue message {message!r}"
)
# 3) The other three views must not produce any issues, since they
# were given their full expected column sets.
for other in _VIEW_NAMES:
if other == view:
continue
assert _issues_for_table(issues, other) == [], (
f"Unexpected issue for unaffected view {other!r}: "
f"{_issues_for_table(issues, other)!r}"
)
# ---------------------------------------------------------------------------
# Positive case: full column sets -> no issues for the four views
# ---------------------------------------------------------------------------
def test_full_column_sets_for_all_four_views_yield_no_issues() -> None:
"""Property 14 - positive direction.
When every view exposes its full expected column set, the verifier
must return zero issues for the four configured team views.
"""
db = _StubDB(_full_columns_for_all_views())
issues = verify_required_columns(db=db, expected=_expected_map())
issues_for_team_views = [
issue for issue in issues if issue.table in _EXPECTED_COLUMNS
]
assert issues_for_team_views == []
# ---------------------------------------------------------------------------
# Sanity check: the configured map in ``build_server`` matches the
# canonical map used by this test. This is not part of Property 14 but
# guards against silent drift between the schema verifier wiring and
# the test's source of truth.
# ---------------------------------------------------------------------------
def test_canonical_map_matches_mcp_server_configuration() -> None:
"""The four-view subset of ``schema_expected`` matches this test."""
import inspect
from teamlandkarte_mcp import mcp_server
source = inspect.getsource(mcp_server.build_server)
for view, cols in _EXPECTED_COLUMNS.items():
assert view in source, (
f"View {view!r} missing from mcp_server.build_server"
)
for col in cols:
assert f'"{col}"' in source, (
f"Column {col!r} for view {view!r} missing "
f"from mcp_server.build_server"
)
@@ -0,0 +1,429 @@
# Feature: team-profile-matching, Property 8: Score-Pfad liefert gültige Score- und Kategorie-Werte # noqa: E501
"""Property-based tests for the team score pipeline.
Validates Property 8 from the team-profile-matching design:
*Für jede* Liste ``teams: list[Team]`` und alle gültigen
:class:`Requirements` ``req`` liefert
``Matcher.match_teams(teams, req,
top_competency_weight=cfg.matching.team.top_competency_weight)`` ein
:class:`TeamMatchResult`, in dem für jedes :class:`ScoredTeam` gilt:
* ``0.0 <= competence_score <= 1.0``,
``0.0 <= role_score <= 1.0``,
``0.0 <= overall_score <= 1.0``
* ``category ∈ {"Top","Good","Partial","Low","Irrelevant"}``
* ``category`` ist konsistent mit ``categorize(overall_score,
cfg.matching)`` (gleiche Schwellwerte wie für Kapazitäten).
* Die Verfügbarkeitsfelder von ``req`` (``date_start``, ``date_end``)
ändern weder die Liste noch die Scores (Anforderung 6.7).
The test injects a deterministic stub :class:`SimilarityEngine` so it
neither depends on Azure OpenAI nor on any network access. Because the
stub returns the same payload for the same ``(required, candidate)``
pair, score outputs are reproducible across runs and identical for
``Requirements`` instances that differ only in date fields.
**Validates: Requirements 6.1, 6.2, 6.3, 6.5, 6.7**
"""
from __future__ import annotations
from datetime import date
from difflib import SequenceMatcher
from typing import Any
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.config import MatchingConfig
from teamlandkarte_mcp.matching.matcher import Matcher
from teamlandkarte_mcp.matching.scorer import categorize
from teamlandkarte_mcp.models import (
Requirements,
Team,
TeamCompetence,
)
# Allowed score-mode categories (Anforderung 6.5).
_ALLOWED_CATEGORIES = {"Top", "Good", "Partial", "Low", "Irrelevant"}
# ---------------------------------------------------------------------------
# Stub SimilarityEngine
# ---------------------------------------------------------------------------
class _StubSimilarityEngine:
"""Deterministic stand-in for :class:`SimilarityEngine`.
Returns substring/ratio-based similarity scores using
:class:`difflib.SequenceMatcher`. The role-score is also derived
deterministically from the two role strings, so two ``Requirements``
instances that differ only in date fields produce identical
similarity payloads for every team.
"""
async def compute_competence_similarity(
self,
required: list[str],
candidate: list[str],
global_index: Any = None,
) -> dict[str, dict[str, object]]:
del global_index # global IDF not relevant for the deterministic stub
result: dict[str, dict[str, object]] = {}
for req in required:
if not candidate:
result[req] = {
"score": 0.0,
"best_match": None,
"rationale": "stub: no candidates",
}
continue
best_pair = max(
(
(c, SequenceMatcher(None, req, c).ratio())
for c in candidate
),
key=lambda pair: (pair[1], pair[0]),
)
best, score = best_pair
result[req] = {
"score": float(score),
"best_match": best,
"rationale": f"stub ratio {score:.3f}",
}
return result
async def compute_role_similarity(
self,
required_role: str | None,
candidate_role: str | None,
) -> float:
a = (required_role or "").strip()
b = (candidate_role or "").strip()
if not a and not b:
return 0.0
if not a or not b:
return 0.0
return float(SequenceMatcher(None, a, b).ratio())
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Plain ASCII text keeps tokenisation predictable and avoids whitespace-only
# strings that the matcher's input filtering would drop.
_name = st.text(
alphabet=st.sampled_from(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -+"
),
min_size=1,
max_size=12,
).filter(lambda s: s.strip() != "")
_required_name = st.text(
alphabet=st.sampled_from(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .#"
),
min_size=2,
max_size=12,
).filter(lambda s: s.strip() != "")
_team_competence = st.builds(
TeamCompetence,
name=_name,
top_competency=st.booleans(),
)
@st.composite
def _team(draw: st.DrawFn, team_id_pool: list[str]) -> Team:
tid = draw(st.sampled_from(team_id_pool))
competences = draw(
st.lists(
_team_competence,
min_size=0,
max_size=5,
unique_by=lambda c: c.name,
)
)
return Team(
team_id=tid,
ouid=f"ou-{tid}",
team_name=f"Team {tid}",
focus_name=draw(st.one_of(st.just(""), _name)),
about_us="",
offerings="",
interests="",
competences=competences,
references=[],
)
@st.composite
def _team_list(draw: st.DrawFn) -> list[Team]:
n = draw(st.integers(min_value=0, max_value=4))
if n == 0:
return []
pool = [f"t{i}" for i in range(n)]
return [draw(_team(team_id_pool=[pool[i]])) for i in range(n)]
_required_list = st.lists(
_required_name, min_size=1, max_size=4, unique=True
)
_role_name = st.one_of(st.none(), _required_name)
@st.composite
def _date_range(draw: st.DrawFn) -> tuple[date | None, date | None]:
"""Generate an arbitrary (date_start, date_end) pair (each may be None)."""
def _maybe_date() -> Any:
return st.one_of(
st.none(),
st.dates(
min_value=date(2000, 1, 1),
max_value=date(2099, 12, 31),
),
)
a = draw(_maybe_date())
b = draw(_maybe_date())
return a, b
_top_weight = st.floats(
min_value=1.0,
max_value=5.0,
allow_nan=False,
allow_infinity=False,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _new_matcher() -> Matcher:
return Matcher(
similarity=_StubSimilarityEngine(), # type: ignore[arg-type]
cfg=MatchingConfig(),
)
def _result_signature(
result: Any,
) -> list[tuple[str, float, float, float, str]]:
"""Project a TeamMatchResult.scored list into a comparable tuple list.
Order matters: the matcher sorts by overall score descending, so
invariance under date-field changes implies identical sequences.
"""
return [
(
s.team.team_id,
s.competence_score,
s.role_score,
s.overall_score,
s.category,
)
for s in result.scored
]
# ---------------------------------------------------------------------------
# Property 8: Score range, category set, category ↔ overall_score consistency.
# ---------------------------------------------------------------------------
# **Validates: Requirements 6.1, 6.2, 6.3, 6.5**
@settings(
max_examples=100,
deadline=None,
suppress_health_check=[
HealthCheck.too_slow,
HealthCheck.function_scoped_fixture,
],
)
@given(
teams=_team_list(),
required=_required_list,
top_weight=_top_weight,
role_name=_role_name,
dates=_date_range(),
)
@pytest.mark.asyncio
async def test_team_score_pipeline_validity(
teams: list[Team],
required: list[str],
top_weight: float,
role_name: str | None,
dates: tuple[date | None, date | None],
) -> None:
"""Every scored team has scores in [0,1] and a consistent category."""
cfg = MatchingConfig()
requirements = Requirements(
role_name=role_name,
competences=required,
date_start=dates[0],
date_end=dates[1],
)
matcher = _new_matcher()
result = await matcher.match_teams(
teams,
requirements,
top_competency_weight=top_weight,
)
# The flat list contains exactly one entry per input team and the
# category buckets together also contain exactly that many entries.
assert len(result.scored) == len(teams)
bucket_total = sum(len(v) for v in result.by_category.values())
assert bucket_total == len(teams)
for scored in result.scored:
# Score ranges (Anforderung 6.1, 6.2, 6.3).
assert 0.0 <= scored.competence_score <= 1.0, (
f"competence_score out of [0,1]: {scored.competence_score!r} "
f"for team_id={scored.team.team_id!r}"
)
assert 0.0 <= scored.role_score <= 1.0, (
f"role_score out of [0,1]: {scored.role_score!r} "
f"for team_id={scored.team.team_id!r}"
)
assert 0.0 <= scored.overall_score <= 1.0, (
f"overall_score out of [0,1]: {scored.overall_score!r} "
f"for team_id={scored.team.team_id!r}"
)
# Category set (Anforderung 6.5).
assert scored.category in _ALLOWED_CATEGORIES, (
f"category {scored.category!r} not in {_ALLOWED_CATEGORIES} "
f"for team_id={scored.team.team_id!r}"
)
# Category ↔ overall_score consistency (same thresholds as for
# capacities, via categorize() in matching/scorer.py).
expected_category = categorize(scored.overall_score, cfg)
assert scored.category == expected_category, (
"category inconsistent with categorize(overall_score, cfg): "
f"got {scored.category!r}, expected {expected_category!r} "
f"for overall_score={scored.overall_score!r}, "
f"team_id={scored.team.team_id!r}"
)
# by_category keys are a subset of the allowed set and bucket
# contents are consistent with each entry's own category.
for cat, items in result.by_category.items():
assert cat in _ALLOWED_CATEGORIES, (
f"by_category key {cat!r} not in {_ALLOWED_CATEGORIES}"
)
for item in items:
assert item.category == cat, (
f"item category {item.category!r} placed in bucket {cat!r}"
)
# ---------------------------------------------------------------------------
# Property 8 (cont.): availability fields do not change list or scores.
# ---------------------------------------------------------------------------
# **Validates: Requirements 6.7**
@settings(
max_examples=100,
deadline=None,
suppress_health_check=[
HealthCheck.too_slow,
HealthCheck.function_scoped_fixture,
],
)
@given(
teams=_team_list(),
required=_required_list,
top_weight=_top_weight,
role_name=_role_name,
dates_a=_date_range(),
dates_b=_date_range(),
)
@pytest.mark.asyncio
async def test_team_score_pipeline_availability_invariance(
teams: list[Team],
required: list[str],
top_weight: float,
role_name: str | None,
dates_a: tuple[date | None, date | None],
dates_b: tuple[date | None, date | None],
) -> None:
"""Two requirements differing only in dates yield identical results."""
req_a = Requirements(
role_name=role_name,
competences=required,
date_start=dates_a[0],
date_end=dates_a[1],
)
req_b = Requirements(
role_name=role_name,
competences=required,
date_start=dates_b[0],
date_end=dates_b[1],
)
matcher = _new_matcher()
result_a = await matcher.match_teams(
teams, req_a, top_competency_weight=top_weight
)
result_b = await matcher.match_teams(
teams, req_b, top_competency_weight=top_weight
)
sig_a = _result_signature(result_a)
sig_b = _result_signature(result_b)
assert sig_a == sig_b, (
"Availability date fields must not change the scored list: "
f"a={sig_a!r}, b={sig_b!r}"
)
# Bucket structure must also be identical (same keys, same items in
# each bucket, same order).
assert set(result_a.by_category.keys()) == set(result_b.by_category.keys())
for cat in result_a.by_category:
items_a = [
(
s.team.team_id,
s.competence_score,
s.role_score,
s.overall_score,
s.category,
)
for s in result_a.by_category[cat]
]
items_b = [
(
s.team.team_id,
s.competence_score,
s.role_score,
s.overall_score,
s.category,
)
for s in result_b.by_category[cat]
]
assert items_a == items_b, (
f"Bucket {cat!r} differs under availability change: "
f"a={items_a!r}, b={items_b!r}"
)
@@ -0,0 +1,241 @@
# Feature: team-profile-matching, Property 7: Top-Kompetenz-Monotonie im Score-Matching
"""Property-based tests for top-competency monotonicity in team scoring.
Validates Property 7 from the team-profile-matching design:
*Für alle* :class:`Requirements`, alle Listen ``cs`` von Kompetenz-Namen
und alle Konfigurationswerte ``top_weight 1.0`` gilt:
``Matcher.match_teams([T_top], req, top_competency_weight=top_weight)``
ergibt für ``T_top`` einen ``competence_score``, der **größer oder
gleich** dem ``competence_score`` von ``T_plain`` ist, wobei
- ``T_plain`` ein Team mit ``competences=[TeamCompetence(c,
top_competency=False) for c in cs]`` ist,
- ``T_top`` ein Team mit identischer ``competences``-Liste, jedoch
``top_competency=True`` für alle Einträge ist.
Außerdem ist bei ``top_weight == 1.0`` die Differenz exakt ``0.0``
(Anforderung 6.4: Top-Markierung ohne Gewicht-Effekt).
The test injects a deterministic stub :class:`SimilarityEngine` so it
neither depends on Azure OpenAI nor on the real BM25 index. The stub's
``compute_competence_similarity`` returns the same payload for both
teams (since both teams share the same competence list), which is
the precondition for a meaningful T_top vs T_plain comparison.
**Validates: Requirements 6.4**
"""
from __future__ import annotations
from difflib import SequenceMatcher
from typing import Any
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.config import MatchingConfig
from teamlandkarte_mcp.matching.matcher import Matcher
from teamlandkarte_mcp.models import (
Requirements,
Team,
TeamCompetence,
)
# ---------------------------------------------------------------------------
# Stub SimilarityEngine
# ---------------------------------------------------------------------------
class _StubSimilarityEngine:
"""Deterministic stand-in for :class:`SimilarityEngine`.
Returns substring/ratio-based scores using
:class:`difflib.SequenceMatcher`. Crucially, the stub depends only on
``(required, candidate)``; ``global_index`` is ignored. This guarantees
that both ``T_top`` and ``T_plain`` (which share the same competence
list) receive identical raw scores **and** identical ``best_match``
values, so any score difference originates exclusively from the
top-competency weight under test.
"""
async def compute_competence_similarity(
self,
required: list[str],
candidate: list[str],
global_index: Any = None,
) -> dict[str, dict[str, object]]:
del global_index # global IDF irrelevant for the deterministic stub
result: dict[str, dict[str, object]] = {}
for req in required:
if not candidate:
result[req] = {
"score": 0.0,
"best_match": None,
"rationale": "stub: no candidates",
}
continue
# Deterministic non-trivial scoring via SequenceMatcher.
best_pair = max(
((c, SequenceMatcher(None, req, c).ratio()) for c in candidate),
key=lambda pair: (pair[1], pair[0]),
)
best, score = best_pair
result[req] = {
"score": float(score),
"best_match": best,
"rationale": f"stub ratio {score:.3f}",
}
return result
async def compute_role_similarity(
self,
required_role: str | None,
candidate_role: str | None,
) -> float:
del required_role, candidate_role # role similarity is fixed
# Constant role score: this property only constrains the
# competence_score, so role similarity is fixed and irrelevant.
return 0.5
def _make_team(
*,
team_id: str,
competences: list[str],
top: bool,
) -> Team:
return Team(
team_id=team_id,
ouid=f"ou-{team_id}",
team_name=f"Team {team_id}",
focus_name="Beliebiger Schwerpunkt",
about_us="",
offerings="",
interests="",
competences=[
TeamCompetence(name=c, top_competency=top) for c in competences
],
references=[],
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# Plain ASCII letters keep tokenization predictable and prevent Hypothesis
# from synthesising whitespace-only strings that the matcher's input
# filtering would drop.
_competence_name = st.text(
alphabet=st.sampled_from(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -+"
),
min_size=2,
max_size=20,
).filter(lambda s: s.strip() != "")
# Required competences are intentionally drawn from a slightly different
# alphabet pool so that the stub's SequenceMatcher returns a wide range of
# scores (including some near-zero ones), which exercises the
# clamp-to-1.0 path inside the matcher's top-weight multiplication.
_required_name = st.text(
alphabet=st.sampled_from(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .#"
),
min_size=2,
max_size=20,
).filter(lambda s: s.strip() != "")
_competence_list = st.lists(_competence_name, min_size=1, max_size=6, unique=True)
_required_list = st.lists(_required_name, min_size=1, max_size=4, unique=True)
# **Validates: Requirements 6.4**
@settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.too_slow],
)
@given(
cs=_competence_list,
required=_required_list,
top_weight=st.floats(
min_value=1.0,
max_value=5.0,
allow_nan=False,
allow_infinity=False,
),
role_name=st.one_of(st.none(), _required_name),
)
@pytest.mark.asyncio
async def test_top_competency_monotonicity_in_score_matching(
cs: list[str],
required: list[str],
top_weight: float,
role_name: str | None,
) -> None:
"""Property 7: top-marker never lowers competence_score; weight 1.0 ⇒ no diff.
Both teams share the same competence list ``cs``. Only the
``top_competency`` flag differs:
* ``T_top`` flags every competence as a top competency,
* ``T_plain`` flags none of them as top competencies.
Under ``Matcher.match_teams`` with ``top_competency_weight = top_weight``
the resulting ``competence_score`` for ``T_top`` must therefore be
``>=`` the score for ``T_plain`` for every ``top_weight >= 1.0``, and
it must be **exactly equal** when ``top_weight == 1.0``.
"""
team_top = _make_team(team_id="top", competences=cs, top=True)
team_plain = _make_team(team_id="plain", competences=cs, top=False)
requirements = Requirements(
role_name=role_name,
competences=required,
date_start=None,
date_end=None,
)
matcher = Matcher(
similarity=_StubSimilarityEngine(), # type: ignore[arg-type]
cfg=MatchingConfig(),
)
# Run the two teams in separate matcher invocations so the global
# BM25 corpora are identical (each matcher sees a single team with
# the same competence list) and any score difference is provably
# caused by the top-competency weight alone.
result_top = await matcher.match_teams(
[team_top], requirements, top_competency_weight=top_weight
)
result_plain = await matcher.match_teams(
[team_plain], requirements, top_competency_weight=top_weight
)
assert len(result_top.scored) == 1
assert len(result_plain.scored) == 1
score_top = result_top.scored[0].competence_score
score_plain = result_plain.scored[0].competence_score
# Monotonicity: top-marked competences must never reduce the score.
assert score_top >= score_plain - 1e-12, (
"T_top.competence_score must be >= T_plain.competence_score "
f"for top_weight={top_weight!r}, "
f"got top={score_top!r} plain={score_plain!r}"
)
# Identity at top_weight == 1.0: the marker has no scoring effect
# because the multiplier is 1.0 in both branches.
if top_weight == 1.0:
assert score_top == score_plain, (
"At top_weight == 1.0 the top marker must have no effect, "
f"got top={score_top!r} plain={score_plain!r}"
)
@@ -0,0 +1,429 @@
# Feature: team-profile-matching, Task 9.11
"""Unit tests for the team-related MCP tools.
Validates the following acceptance criteria:
* **Anforderung 1.2**: The existing ``find_matching_capacities`` tool keeps its
Markdown output structure after the team-profile refactoring (snapshot-style
assertion on stable substrings).
* **Anforderung 1.3**: ``find_matching_teams`` is registered with ``FastMCP``.
* **Anforderung 9.1**: ``list_teams`` is registered and renders a Markdown
table whose headers are exactly ``Team Id``, ``Team Name``, ``Schwerpunkt``,
``Anzahl Kompetenzen``, ``Anzahl Referenzen``.
* **Anforderung 9.2**: ``get_team_details`` is registered and renders the full
layout (summary table + ``## Über uns`` / ``## Leistungen`` / ``## Interessen``
/ ``## Kompetenzen`` / ``## Referenzen`` / ``## Next steps``) for an existing
team.
* **Anforderung 9.3**: ``get_team_details`` returns an error message that
contains the requested (unknown) ``team_id`` substring when no team matches.
* **Anforderung 9.4**: Top competences are rendered with the ``(Top)`` suffix
and references with a ``partner_name`` are rendered as
``**<partner>**: <projects>`` (bold partner name).
The tests use a minimal fake ``DBClient`` and stub out ``create_db_client`` so
that no real database is touched. The LLM is not exercised here.
"""
from __future__ import annotations
from typing import Any
import pytest
from teamlandkarte_mcp.mcp_server import build_server
from teamlandkarte_mcp.models import (
Capacity,
Team,
TeamCompetence,
TeamReference,
)
_CONFIG_TEMPLATE = """
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[matching.team]
top_competency_weight=1.5
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip()
def _make_team(team_id: str = "t1") -> Team:
"""Construct a deterministic team with mixed top/non-top competences and
a reference with and without partner name."""
return Team(
team_id=team_id,
ouid=f"ou-{team_id}",
team_name=f"Team {team_id.upper()}",
focus_name="Backend Engineering",
about_us="We build distributed backends.",
offerings="APIs, ETL pipelines, cloud migrations.",
interests="Event-driven architectures.",
competences=[
TeamCompetence(name="Python", top_competency=True),
TeamCompetence(name="Java", top_competency=False),
],
references=[
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
TeamReference(partner_name="", projects="Internal tooling"),
],
)
class _FakeDB:
"""Minimal fake ``DBClient`` covering all tools used by this test
module (capacities + teams).
``get_all_teams`` returns the configured list of teams; ``get_team_by_id``
looks up by ``team_id`` and returns ``None`` for unknown ids.
"""
def __init__(
self,
*,
teams: list[Team] | None = None,
capacities: list[Capacity] | None = None,
) -> None:
self._teams = list(teams or [])
self._caps = list(capacities or [])
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_all_role_names(self) -> list[str]:
return ["Backend Engineering"]
def get_all_competence_names(self) -> list[str]:
return ["Python", "Java"]
def get_open_tasks(self, limit: int = 20):
return []
def get_all_capacities_with_competences(self):
return list(self._caps)
def get_recent_free_capacities(self, limit: int = 20):
return list(self._caps)[:limit]
def get_capacity_by_id(self, capacity_id): # pragma: no cover
return None
def get_capacity_description(self, capacity_id): # pragma: no cover
return None
def get_capacity_references(self, capacity_id): # pragma: no cover
return []
def get_capacity_certificates(self, capacity_id): # pragma: no cover
return []
def get_all_teams(self) -> list[Team]:
return list(self._teams)
def get_team_by_id(self, team_id) -> Team | None:
for t in self._teams:
if t.team_id == str(team_id):
return t
return None
def _result_to_text(result: Any) -> str:
"""Coerce a FastMCP ``call_tool`` result to a plain text string."""
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(
structured.get("result"), str
):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
if isinstance(result, str):
return result
return str(result)
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
return _result_to_text(await srv.call_tool(name, args))
def _build_server(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
*,
teams: list[Team] | None = None,
capacities: list[Capacity] | None = None,
):
cfg = tmp_path / "config.toml"
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
fake_db = _FakeDB(teams=teams, capacities=capacities)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_a, **_k: fake_db)
return build_server(str(cfg))
# ---------------------------------------------------------------------------
# Tool registration tests (Anforderungen 1.3, 9.1, 9.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_matching_teams_tool_is_registered(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``find_matching_teams`` must be registered with FastMCP."""
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team()])
tool_names = {t.name for t in await srv.list_tools()}
assert "find_matching_teams" in tool_names, (
f"expected 'find_matching_teams' to be registered; got {sorted(tool_names)!r}"
)
@pytest.mark.asyncio
async def test_list_teams_tool_is_registered(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``list_teams`` must be registered with FastMCP."""
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team()])
tool_names = {t.name for t in await srv.list_tools()}
assert "list_teams" in tool_names, (
f"expected 'list_teams' to be registered; got {sorted(tool_names)!r}"
)
@pytest.mark.asyncio
async def test_get_team_details_tool_is_registered(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``get_team_details`` must be registered with FastMCP."""
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team()])
tool_names = {t.name for t in await srv.list_tools()}
assert "get_team_details" in tool_names, (
f"expected 'get_team_details' to be registered; got {sorted(tool_names)!r}"
)
# ---------------------------------------------------------------------------
# Markdown layout tests (Anforderungen 9.1, 9.2, 9.4)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_teams_renders_markdown_table(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``list_teams`` must render a Markdown table with the spec headers."""
teams = [_make_team("t1"), _make_team("t2")]
srv = _build_server(monkeypatch, tmp_path, teams=teams)
out = await _call_tool(srv, "list_teams", {"limit": 20})
for header in (
"Team Id",
"Team Name",
"Schwerpunkt",
"Anzahl Kompetenzen",
"Anzahl Referenzen",
):
assert header in out, (
f"missing header {header!r} in list_teams output:\n{out}"
)
# Both team_ids must show up in the body of the table.
assert "t1" in out, f"missing team_id 't1' in list_teams output:\n{out}"
assert "t2" in out, f"missing team_id 't2' in list_teams output:\n{out}"
@pytest.mark.asyncio
async def test_get_team_details_renders_full_layout(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``get_team_details`` must render summary table + all sections."""
team = _make_team("t1")
srv = _build_server(monkeypatch, tmp_path, teams=[team])
out = await _call_tool(srv, "get_team_details", {"team_id": "t1"})
# Summary table headers (same as list_teams).
for header in (
"Team Id",
"Team Name",
"Schwerpunkt",
"Anzahl Kompetenzen",
"Anzahl Referenzen",
):
assert header in out, (
f"missing summary header {header!r} in get_team_details output:\n{out}"
)
# All required sections.
for section in (
"## Über uns",
"## Leistungen",
"## Interessen",
"## Kompetenzen",
"## Referenzen",
"## Next steps",
):
assert section in out, (
f"missing section {section!r} in get_team_details output:\n{out}"
)
# Top-competence marker (Anforderung 9.4 / 5.5).
# The team has ``Python`` flagged top, ``Java`` not.
assert "Python (Top)" in out, (
"expected 'Python (Top)' marker in Kompetenzen section, "
f"output was:\n{out}"
)
assert "Java" in out, f"expected 'Java' to appear in output:\n{out}"
assert "Java (Top)" not in out, (
"Non-top competence 'Java' must not be marked as Top, "
f"output was:\n{out}"
)
# Bold partner_name in references (Anforderung 9.4 / 5.6).
assert "**DB Cargo**" in out, (
"expected partner_name to be bolded with ** ** in references, "
f"output was:\n{out}"
)
# The reference without partner_name renders only the projects text.
assert "Internal tooling" in out, (
"expected partner-less reference projects text in output:\n" + out
)
@pytest.mark.asyncio
async def test_get_team_details_unknown_team_id(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""``get_team_details`` must mention the unknown id in its error
message (Anforderung 9.3)."""
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team("t1")])
out = await _call_tool(srv, "get_team_details", {"team_id": "unknown"})
assert "Team not found" in out, (
f"expected 'Team not found' in error response, got:\n{out}"
)
assert "unknown" in out, (
f"expected the requested team_id substring 'unknown' in error "
f"response, got:\n{out}"
)
# ---------------------------------------------------------------------------
# find_matching_capacities snapshot test (Anforderung 1.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_matching_capacities_output_structure_unchanged(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""The existing ``find_matching_capacities`` tool keeps its Markdown
output structure after the team-profile refactoring.
This is a snapshot-style assertion on stable, contract-relevant
substrings (``SEARCH_ID=``, ``META=``, ``## Summary``, the Category /
Count summary header, and the per-category Results header). It is
deliberately not byte-exact because per-line scores depend on the
SimilarityEngine; the structural envelope is what callers depend on.
"""
cap = Capacity(
id=1,
owner_name="Alice Example",
role_name="Backend Engineering",
role_level=None,
begin_date=None,
end_date=None,
competences=["Python"],
)
srv = _build_server(
monkeypatch,
tmp_path,
teams=[_make_team("t1")],
capacities=[cap],
)
out = await _call_tool(
srv,
"find_matching_capacities",
{
"role_name": "Backend Engineering",
"competences": ["Python"],
},
)
# Stable contract markers that survive any Score/LLM refactoring.
for marker in (
"SEARCH_ID=",
"META=",
"## Summary",
"| Category | Count |",
):
assert marker in out, (
f"missing structural marker {marker!r} in "
f"find_matching_capacities output:\n{out}"
)
# The default-shown category section header must be present (one of
# the five categories). The exact one depends on score thresholds
# but at least one Results section header must appear.
assert any(
f"## {cat} Results" in out
for cat in ("Top", "Good", "Partial", "Low", "Irrelevant")
), (
"expected at least one '## <Category> Results' header in "
f"find_matching_capacities output:\n{out}"
)
# The capacity-search column layout must include the legacy headers
# (the team refactoring must not have leaked team headers in here).
assert "| ID |" in out, (
f"expected legacy capacity-search 'ID' column in output:\n{out}"
)
assert "Team Name" not in out, (
"team-search column 'Team Name' must not appear in capacity-search "
f"output:\n{out}"
)
@@ -0,0 +1,268 @@
# Feature: team-profile-matching, Property 2: SELECT-Only-Eigenschaft aller neuen Trino-Queries
"""Property-based test for the SELECT-only invariant of the six new
``TrinoClient`` methods that back team profiles.
Validates Property 2 from the team-profile-matching design:
*Für alle* Aufrufe von ``TrinoClient.get_all_teams``,
``get_team_by_id``, ``get_team_competences``,
``batch_get_team_competences``, ``get_team_references`` und
``batch_get_team_references`` (mit beliebigen gültigen Eingaben) ist die
ausgeführte SQL-Anfrage ein ``SELECT``- oder ``WITH``-Statement, sodass
``_ensure_select_only(sql)`` ohne Fehler durchläuft.
The test installs a stub cursor that invokes ``_ensure_select_only``
from ``teamlandkarte_mcp.database.read_only`` on *every* SQL string
passed to ``cursor.execute(...)``. Hypothesis generates inputs for the
six new methods (single string OUIDs/team-ids and lists of OUIDs).
After each call, the test asserts that the stub cursor recorded zero
``DatabaseError`` raises and that every recorded statement starts with
``SELECT`` or ``WITH`` once trimmed.
Blank string inputs (e.g. ``""`` or whitespace-only) are valid inputs
that intentionally short-circuit some methods *without* issuing SQL;
those calls are still covered by the property because the property is
"every executed SQL passes the read-only guard", which holds vacuously
when no SQL is executed.
**Validates: Requirements 2.6**
"""
from __future__ import annotations
from typing import Any
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from teamlandkarte_mcp.database.read_only import (
DatabaseError,
_ensure_select_only,
)
from teamlandkarte_mcp.database.trino_client import TrinoClient
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _SelectOnlyCursor:
"""Stub cursor that runs ``_ensure_select_only`` for every SQL.
The cursor records every executed SQL string and re-raises any
``DatabaseError`` from the read-only guard so the test can assert
on the recorded statements after the call returns. ``fetchone`` and
``fetchall`` return canned empty results so all six methods take
their "no rows" path and stay deterministic.
"""
def __init__(self) -> None:
self.executed: list[str] = []
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
# Run the read-only guard; any failure surfaces as ``DatabaseError``
# which makes the property test fail with a clear counterexample.
_ensure_select_only(sql)
self.executed.append(sql)
def fetchone(self) -> Any: # noqa: ANN401
return None
def fetchall(self) -> list[Any]:
return []
class _CursorContext:
"""Context-manager wrapper around a single ``_SelectOnlyCursor``."""
def __init__(self, cursor: _SelectOnlyCursor) -> None:
self._cursor = cursor
def __enter__(self) -> _SelectOnlyCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _make_client() -> tuple[TrinoClient, _SelectOnlyCursor]:
"""Build a ``TrinoClient`` whose ``_cursor`` yields a stub cursor."""
cursor = _SelectOnlyCursor()
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
client._cursor = lambda: _CursorContext(cursor) # type: ignore[method-assign]
return client, cursor
def _assert_select_or_with(sql: str) -> None:
"""Cross-check that ``sql`` starts with ``SELECT``/``WITH``.
This duplicates the contract of ``_ensure_select_only`` so the
property test is robust against future relaxations of that helper:
even if the helper were widened, the property assertion would
catch any non-read-only statement.
"""
head = sql.strip().lower()
assert head.startswith("select") or head.startswith("with"), (
f"executed SQL is not SELECT/WITH: {sql!r}"
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
# OUID/team_id strings: arbitrary text including blanks. The trino-client
# methods explicitly handle blank strings as "no SQL" short-circuits,
# which is fine: the SELECT-only property holds vacuously in that case.
_id_text = st.text(max_size=20)
# Lists of OUIDs for the batch variants. Bound the list size so the
# in-clause stays reasonable; allow empty lists to exercise the
# "no SQL issued" short-circuit.
_id_list = st.lists(_id_text, min_size=0, max_size=8)
# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------
_SETTINGS = settings(
max_examples=100,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@_SETTINGS
@given(st.data())
def test_get_all_teams_executes_select_only(data: st.DataObject) -> None:
"""``get_all_teams()`` must only execute SELECT/WITH statements."""
# ``get_all_teams`` takes no inputs but we still drive Hypothesis
# so the example counter and shrinking machinery match the rest
# of the file.
data.draw(st.just(None))
client, cursor = _make_client()
try:
client.get_all_teams()
except DatabaseError as exc: # pragma: no cover - property failure
raise AssertionError(
f"_ensure_select_only rejected a SQL statement: {exc}"
) from exc
for sql in cursor.executed:
_assert_select_or_with(sql)
@_SETTINGS
@given(team_id=_id_text)
def test_get_team_by_id_executes_select_only(team_id: str) -> None:
"""``get_team_by_id(team_id)`` must only execute SELECT/WITH."""
client, cursor = _make_client()
try:
client.get_team_by_id(team_id)
except DatabaseError as exc: # pragma: no cover - property failure
raise AssertionError(
f"_ensure_select_only rejected a SQL statement for "
f"team_id={team_id!r}: {exc}"
) from exc
for sql in cursor.executed:
_assert_select_or_with(sql)
@_SETTINGS
@given(ouid=_id_text)
def test_get_team_competences_executes_select_only(ouid: str) -> None:
"""``get_team_competences(ouid)`` must only execute SELECT/WITH."""
client, cursor = _make_client()
try:
client.get_team_competences(ouid)
except DatabaseError as exc: # pragma: no cover - property failure
raise AssertionError(
f"_ensure_select_only rejected a SQL statement for "
f"ouid={ouid!r}: {exc}"
) from exc
for sql in cursor.executed:
_assert_select_or_with(sql)
@_SETTINGS
@given(ouids=_id_list)
def test_batch_get_team_competences_executes_select_only(
ouids: list[str],
) -> None:
"""``batch_get_team_competences(ouids)`` must only execute SELECT/WITH."""
client, cursor = _make_client()
try:
client.batch_get_team_competences(ouids)
except DatabaseError as exc: # pragma: no cover - property failure
raise AssertionError(
f"_ensure_select_only rejected a SQL statement for "
f"ouids={ouids!r}: {exc}"
) from exc
for sql in cursor.executed:
_assert_select_or_with(sql)
@_SETTINGS
@given(ouid=_id_text)
def test_get_team_references_executes_select_only(ouid: str) -> None:
"""``get_team_references(ouid)`` must only execute SELECT/WITH."""
client, cursor = _make_client()
try:
client.get_team_references(ouid)
except DatabaseError as exc: # pragma: no cover - property failure
raise AssertionError(
f"_ensure_select_only rejected a SQL statement for "
f"ouid={ouid!r}: {exc}"
) from exc
for sql in cursor.executed:
_assert_select_or_with(sql)
@_SETTINGS
@given(ouids=_id_list)
def test_batch_get_team_references_executes_select_only(
ouids: list[str],
) -> None:
"""``batch_get_team_references(ouids)`` must only execute SELECT/WITH."""
client, cursor = _make_client()
try:
client.batch_get_team_references(ouids)
except DatabaseError as exc: # pragma: no cover - property failure
raise AssertionError(
f"_ensure_select_only rejected a SQL statement for "
f"ouids={ouids!r}: {exc}"
) from exc
for sql in cursor.executed:
_assert_select_or_with(sql)
@@ -0,0 +1,259 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
import pytest
from teamlandkarte_mcp.mcp_server import build_server
@dataclass
class FakeTask:
id: str
name: str | None = None
title: str = ""
description: str = ""
created_date: datetime = datetime(2026, 1, 1)
start_date: date | None = None
end_date: date | None = None
skills: list[str] = None # type: ignore[assignment]
def __post_init__(self):
if self.skills is None:
self.skills = []
class FakeDBClient:
def __init__(self, task: FakeTask):
self._task = task
def test_connection(self) -> None:
return
def get_table_columns(self, table: str) -> list[str]:
if table == "teamlandkarte_v_capacity_roles_latest":
return ["name", "active", "staffing_board_relevant"]
if table == "teamlandkarte_v_capacities_latest":
return ["creation_date"]
if table == "teamlandkarte_v_teams_latest":
return [
"team_id",
"ouid",
"about_us",
"offerings",
"interests",
"focus_name",
]
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
return ["id", "name"]
if table == "teamlandkarte_v_teammeter_team_competences_latest":
return ["ouid", "competence_id", "top_competency"]
if table == "teamlandkarte_v_team_references_latest":
return ["ouid", "partner_id", "projects"]
return []
def get_open_tasks(self, limit: int = 20):
return [self._task]
def get_task_by_id(self, task_id: str):
if task_id != self._task.id:
return None
return self._task
def get_task_by_name(self, name: str):
if not name:
return None
if getattr(self._task, "name", None) == name:
return self._task
return None
def get_all_capacities_with_competences(self): # pragma: no cover
return []
def get_recent_free_capacities(self, limit: int = 20): # pragma: no cover
return []
def get_capacity_by_id(self, capacity_id): # pragma: no cover
return None
def get_all_role_names(self) -> list[str]:
return ["Cloud Engineer"]
def get_all_competence_names(self) -> list[str]:
return ["AWS", "Kubernetes"]
def _result_to_text(result: Any) -> str:
if isinstance(result, tuple) and len(result) == 2:
content, structured = result
if isinstance(structured, dict) and isinstance(structured.get("result"), str):
return structured["result"]
if isinstance(content, list) and content:
text = getattr(content[0], "text", None)
if isinstance(text, str):
return text
return str(result)
def _call_tool(srv: Any, name: str, args: dict[str, Any] | None = None) -> str:
if args is None:
args = {}
import asyncio
return _result_to_text(asyncio.run(srv.call_tool(name, args)))
def test_infer_primary_role_table_output(monkeypatch, tmp_path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
task = FakeTask(
id="t1",
title="Task",
description="We need AWS and Kubernetes work.",
created_date=datetime(2026, 1, 1, 10, 0, 0),
start_date=None,
end_date=None,
skills=["AWS"],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient(task))
srv = build_server(str(cfg))
out = _call_tool(
srv,
"infer_primary_role",
{"task_text": task.description},
)
assert "| Role | Similarity |" in out
@pytest.mark.asyncio
async def test_get_task_details_table_first(monkeypatch, tmp_path) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
task = FakeTask(
id="t1",
title="Title",
description="Desc",
created_date=datetime(2026, 1, 2, 12, 0, 0),
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 28),
skills=["AWS", "Kubernetes"],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient(task))
srv = build_server(str(cfg))
content, structured = await srv.call_tool(
"get_task_details",
{"task_id": "t1"},
)
out = _result_to_text((content, structured))
assert out.lstrip().startswith("|")
assert "| Task ID |" in out
assert "## Description" in out
@pytest.mark.asyncio
async def test_validate_task_requirements_single_table(
monkeypatch,
tmp_path,
) -> None:
cfg = tmp_path / "config.toml"
cfg.write_text(
"""
[database]
host='x'
port=1
username='u'
password='p'
backend='trino'
[matching]
competence_weight=0.8
role_weight=0.2
require_confirmation=false
[cache]
db_ttl_hours=1
search_ttl_minutes=10
max_size=10
""".strip(),
encoding="utf-8",
)
task = FakeTask(
id="t1",
title="Title",
description="We need AWS.",
created_date=datetime(2026, 1, 2, 12, 0, 0),
start_date=None,
end_date=None,
skills=["AWS"],
)
import teamlandkarte_mcp.mcp_server as mod
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient(task))
srv = build_server(str(cfg))
content, structured = await srv.call_tool(
"validate_task_requirements", {"task_id": "t1"}
)
out = _result_to_text((content, structured))
assert "| Task ID |" in out
assert "| Time range (DB) |" in out
assert "## Summary" in out
assert "## Inferred Primary Role" in out
assert "## Inferred Competences" in out
@@ -0,0 +1,365 @@
"""Unit tests for the new capacity full-text DB methods on TrinoClient.
Covers single-row and batch variants for capacity description,
certificates, and references including the LEFT JOIN on
`teamlandkarte_v_partners_latest`.
"""
from __future__ import annotations
import re
import pytest
from teamlandkarte_mcp.database.trino_client import TrinoClient
class _FakeCursor:
"""Minimal cursor double recording executes and returning canned rows."""
def __init__(self, rows: list[tuple] | None = None) -> None:
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
self._rows: list[tuple] = rows or []
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
if params is None:
params_tuple: tuple[object, ...] | None = None
elif isinstance(params, tuple):
params_tuple = params
else:
params_tuple = tuple(params)
self.executed.append((sql, params_tuple))
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return list(self._rows)
class _FakeCursorContext:
def __init__(self, cursor: _FakeCursor):
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _mk_client(cur: _FakeCursor) -> TrinoClient:
client = TrinoClient(_FakeConfig())
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
return client
def _norm_sql(sql: str) -> str:
return re.sub(r"\s+", " ", sql).strip()
def _assert_select_only(sql: str) -> None:
upper = sql.strip().upper()
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
# ---------------------------------------------------------------------------
# Single-row methods
# ---------------------------------------------------------------------------
def test_get_capacity_description_one_query_select_only_and_trims() -> None:
cur = _FakeCursor(rows=[(" hello world ",)])
c = _mk_client(cur)
out = c.get_capacity_description(42)
assert len(cur.executed) == 1, "exactly one SQL query expected"
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
assert "WHERE id = ?" in sql_n
assert params == (42,)
assert out == "hello world"
def test_get_capacity_description_null_returns_none() -> None:
cur = _FakeCursor(rows=[(None,)])
c = _mk_client(cur)
assert c.get_capacity_description(1) is None
def test_get_capacity_description_whitespace_returns_none() -> None:
cur = _FakeCursor(rows=[(" \t ",)])
c = _mk_client(cur)
assert c.get_capacity_description(1) is None
def test_get_capacity_description_no_row_returns_none() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
assert c.get_capacity_description(99) is None
def test_get_capacity_certificates_one_query_filters_empty() -> None:
cur = _FakeCursor(
rows=[
("ISTQB Foundation",),
("",),
(" ",),
(None,),
("PMP ",),
]
)
c = _mk_client(cur)
out = c.get_capacity_certificates(7)
assert len(cur.executed) == 1
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_capacity_certificates_latest" in sql_n
assert "WHERE capacity_id = ?" in sql_n
assert params == (7,)
assert out == ["ISTQB Foundation", "PMP"]
def test_get_capacity_certificates_no_rows_returns_empty_list() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
assert c.get_capacity_certificates(123) == []
assert len(cur.executed) == 1
def test_get_capacity_references_single_query_includes_partner_left_join() -> None:
cur = _FakeCursor(
rows=[
("Project Alpha", "Acme GmbH"),
("Project Beta", ""), # NULL partner_id → COALESCE → ""
]
)
c = _mk_client(cur)
out = c.get_capacity_references(11)
assert len(cur.executed) == 1, "partner LEFT JOIN must be in the same query"
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_capacity_references_latest" in sql_n
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql_n
assert "r.partner_id = p.id" in sql_n
assert "WHERE r.capacity_id = ?" in sql_n
assert params == (11,)
assert out == [
{"projects": "Project Alpha", "partner_name": "Acme GmbH"},
{"projects": "Project Beta", "partner_name": ""},
]
def test_get_capacity_references_filters_empty_projects_keeps_empty_partner() -> None:
cur = _FakeCursor(
rows=[
("", "Acme GmbH"), # empty projects → drop
(" ", "X"), # whitespace only → drop
(None, "Y"), # non-string → drop
("Migration", ""), # empty partner is allowed
]
)
c = _mk_client(cur)
out = c.get_capacity_references(11)
assert out == [{"projects": "Migration", "partner_name": ""}]
def test_get_capacity_references_partner_name_none_normalizes_to_empty() -> None:
"""Defensive: if the driver returns None for partner_name, treat as ''."""
cur = _FakeCursor(rows=[("Project Alpha", None)])
c = _mk_client(cur)
out = c.get_capacity_references(11)
assert out == [{"projects": "Project Alpha", "partner_name": ""}]
# ---------------------------------------------------------------------------
# Batch variants
# ---------------------------------------------------------------------------
def test_batch_get_capacity_descriptions_empty_input_returns_empty_no_sql() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_capacity_descriptions([])
assert out == {}
assert cur.executed == []
def test_batch_get_capacity_descriptions_one_query_groups_and_defaults() -> None:
cur = _FakeCursor(
rows=[
(1, " desc one "),
(2, None),
(3, " "),
]
)
c = _mk_client(cur)
out = c.batch_get_capacity_descriptions([1, 2, 3, 4])
assert len(cur.executed) == 1
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
assert "WHERE id IN (?, ?, ?, ?)" in sql_n
assert params == (1, 2, 3, 4)
assert out == {"1": "desc one", "2": None, "3": None, "4": None}
def test_batch_get_capacity_certificates_empty_input_returns_empty_no_sql() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_capacity_certificates([])
assert out == {}
assert cur.executed == []
def test_batch_get_capacity_certificates_groups_n_to_1_and_defaults() -> None:
cur = _FakeCursor(
rows=[
(1, "ISTQB"),
(1, " PMP "),
(1, ""), # filtered
(2, "AWS Solutions Architect"),
(1, None), # filtered
]
)
c = _mk_client(cur)
out = c.batch_get_capacity_certificates([1, 2, 3])
assert len(cur.executed) == 1
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_capacity_certificates_latest" in sql_n
assert "WHERE capacity_id IN (?, ?, ?)" in sql_n
assert params == (1, 2, 3)
assert out == {
"1": ["ISTQB", "PMP"],
"2": ["AWS Solutions Architect"],
"3": [], # default for missing id
}
def test_batch_get_capacity_references_empty_input_returns_empty_no_sql() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_capacity_references([])
assert out == {}
assert cur.executed == []
def test_batch_get_capacity_references_partner_join_same_statement_and_groups() -> None:
cur = _FakeCursor(
rows=[
(1, "Project Alpha", "Acme GmbH"),
(1, "Project Beta", ""), # NULL partner_id → kept with empty partner
(2, "Migration", "BetaCorp"),
(1, " ", "X"), # filtered (empty projects)
(1, None, "Y"), # filtered (non-string projects)
]
)
c = _mk_client(cur)
out = c.batch_get_capacity_references([1, 2, 3])
assert len(cur.executed) == 1, (
"partner LEFT JOIN must be part of the same single batch query"
)
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_capacity_references_latest" in sql_n
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql_n
assert "r.partner_id = p.id" in sql_n
assert "WHERE r.capacity_id IN (?, ?, ?)" in sql_n
assert params == (1, 2, 3)
assert out == {
"1": [
{"projects": "Project Alpha", "partner_name": "Acme GmbH"},
{"projects": "Project Beta", "partner_name": ""},
],
"2": [{"projects": "Migration", "partner_name": "BetaCorp"}],
"3": [],
}
def test_batch_get_capacity_references_partner_name_none_normalizes_to_empty() -> None:
"""Defensive: driver returns None for partner_name → treated as ''."""
cur = _FakeCursor(rows=[(1, "Project Alpha", None)])
c = _mk_client(cur)
out = c.batch_get_capacity_references([1])
assert out == {
"1": [{"projects": "Project Alpha", "partner_name": ""}],
}
# ---------------------------------------------------------------------------
# SELECT-only guard parametric coverage
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"method,args,rows",
[
("get_capacity_description", (1,), [("x",)]),
("get_capacity_certificates", (1,), [("x",)]),
("get_capacity_references", (1,), [("x", "")]),
("batch_get_capacity_descriptions", ([1, 2],), [(1, "x")]),
("batch_get_capacity_certificates", ([1, 2],), [(1, "x")]),
("batch_get_capacity_references", ([1, 2],), [(1, "x", "")]),
],
)
def test_capacity_fulltext_methods_emit_select_only(
method: str, args: tuple, rows: list[tuple]
) -> None:
cur = _FakeCursor(rows=rows)
c = _mk_client(cur)
getattr(c, method)(*args)
assert cur.executed, f"{method} should have executed a query"
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
sql, _params = cur.executed[0]
_assert_select_only(sql)
@@ -0,0 +1,294 @@
"""Unit tests for `TrinoClient.get_team_competences` and
`batch_get_team_competences`.
Covers SQL shape (INNER JOIN on the competences view, COALESCE for
`top_competency`, deterministic `ORDER BY`), grouping by `ouid` in
the batch variant, NULL-name filtering and empty-input handling.
"""
from __future__ import annotations
import re
import pytest
from teamlandkarte_mcp.database.trino_client import TrinoClient
class _FakeCursor:
"""Minimal cursor double recording executes and returning canned rows."""
def __init__(self, rows: list[tuple] | None = None) -> None:
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
self._rows: list[tuple] = rows or []
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
if params is None:
params_tuple: tuple[object, ...] | None = None
elif isinstance(params, tuple):
params_tuple = params
else:
params_tuple = tuple(params)
self.executed.append((sql, params_tuple))
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return list(self._rows)
class _FakeCursorContext:
def __init__(self, cursor: _FakeCursor):
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _mk_client(cur: _FakeCursor) -> TrinoClient:
client = TrinoClient(_FakeConfig())
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
return client
def _norm_sql(sql: str) -> str:
return re.sub(r"\s+", " ", sql).strip()
def _assert_select_only(sql: str) -> None:
upper = sql.strip().upper()
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
# ---------------------------------------------------------------------------
# Single-row variant
# ---------------------------------------------------------------------------
def test_get_team_competences_returns_empty_for_blank_input() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
assert c.get_team_competences("") == []
assert c.get_team_competences(" ") == []
assert cur.executed == []
def test_get_team_competences_one_query_select_only_and_join_shape() -> None:
cur = _FakeCursor(
rows=[
("Python", True),
("Cloud", False),
]
)
c = _mk_client(cur)
out = c.get_team_competences("OU1")
assert len(cur.executed) == 1
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert (
"FROM teamlandkarte_v_teammeter_team_competences_latest tc"
in sql_n
)
assert "JOIN teamlandkarte_v_competences_latest c" in sql_n
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql_n
assert "WHERE CAST(tc.ouid AS VARCHAR) = ?" in sql_n
assert "COALESCE(tc.top_competency, FALSE)" in sql_n
# Order: top first, then name asc
assert "ORDER BY" in sql_n
assert "CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END" in sql_n
assert "c.name ASC" in sql_n
assert params == ("OU1",)
assert out == [
{"name": "Python", "top_competency": True},
{"name": "Cloud", "top_competency": False},
]
def test_get_team_competences_normalises_null_top_competency() -> None:
# SQL-side COALESCE turns NULL into FALSE, but the Python layer also
# defends against `None` defensively via bool(...).
cur = _FakeCursor(rows=[("Python", None), ("Cloud", 1)])
c = _mk_client(cur)
out = c.get_team_competences("OU1")
assert out == [
{"name": "Python", "top_competency": False},
{"name": "Cloud", "top_competency": True},
]
def test_get_team_competences_filters_rows_without_resolvable_name() -> None:
cur = _FakeCursor(
rows=[
(None, True), # join produced no name → drop
("", False), # empty name → drop
("Python", True),
]
)
c = _mk_client(cur)
assert c.get_team_competences("OU1") == [
{"name": "Python", "top_competency": True},
]
def test_get_team_competences_no_rows_returns_empty_list() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
assert c.get_team_competences("OU1") == []
assert len(cur.executed) == 1
# ---------------------------------------------------------------------------
# Batch variant
# ---------------------------------------------------------------------------
def test_batch_get_team_competences_empty_input_returns_empty_no_sql() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_team_competences([])
assert out == {}
assert cur.executed == []
def test_batch_get_team_competences_groups_and_defaults_to_empty_list() -> None:
cur = _FakeCursor(
rows=[
("OU1", "Python", True),
("OU1", "Cloud", False),
("OU2", "Architecture", True),
]
)
c = _mk_client(cur)
out = c.batch_get_team_competences(["OU1", "OU2", "OU3"])
assert len(cur.executed) == 1, "batch must use exactly one SELECT"
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert (
"FROM teamlandkarte_v_teammeter_team_competences_latest tc"
in sql_n
)
assert "JOIN teamlandkarte_v_competences_latest c" in sql_n
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql_n
assert "WHERE CAST(tc.ouid AS VARCHAR) IN (?, ?, ?)" in sql_n
assert "ORDER BY tc.ouid ASC" in sql_n
assert "CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END" in sql_n
assert "c.name ASC" in sql_n
assert params == ("OU1", "OU2", "OU3")
assert out == {
"OU1": [
{"name": "Python", "top_competency": True},
{"name": "Cloud", "top_competency": False},
],
"OU2": [
{"name": "Architecture", "top_competency": True},
],
"OU3": [],
}
def test_batch_get_team_competences_filters_unresolved_competence_names() -> None:
cur = _FakeCursor(
rows=[
("OU1", None, True), # no resolvable name → drop
("OU1", "Python", True),
("OU2", "", False), # empty name → drop
]
)
c = _mk_client(cur)
out = c.batch_get_team_competences(["OU1", "OU2"])
assert out == {
"OU1": [{"name": "Python", "top_competency": True}],
"OU2": [],
}
def test_batch_get_team_competences_normalises_null_top_competency() -> None:
cur = _FakeCursor(
rows=[
("OU1", "Python", None),
("OU1", "Cloud", 1),
]
)
c = _mk_client(cur)
out = c.batch_get_team_competences(["OU1"])
assert out == {
"OU1": [
{"name": "Python", "top_competency": False},
{"name": "Cloud", "top_competency": True},
],
}
def test_batch_get_team_competences_ignores_blank_ouid_inputs() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_team_competences(["", " "])
assert out == {"": [], " ": []}
assert cur.executed == []
# ---------------------------------------------------------------------------
# SELECT-only guard parametric coverage
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"method,args,rows",
[
("get_team_competences", ("OU1",), [("Python", True)]),
(
"batch_get_team_competences",
(["OU1", "OU2"],),
[("OU1", "Python", True)],
),
],
)
def test_team_competence_methods_emit_select_only(
method: str, args: tuple, rows: list[tuple]
) -> None:
cur = _FakeCursor(rows=rows)
c = _mk_client(cur)
getattr(c, method)(*args)
assert cur.executed, f"{method} should have executed a query"
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
sql, _params = cur.executed[0]
_assert_select_only(sql)
@@ -0,0 +1,222 @@
"""Unit tests for `TrinoClient.get_all_teams` and `get_team_by_id`.
Covers SQL shape (INNER JOIN on the OU view, COALESCE for nullable
text columns, deterministic ORDER BY), aggregation into `Team`
instances, and the `None`-on-no-match contract for
`get_team_by_id`.
"""
from __future__ import annotations
import re
from teamlandkarte_mcp.database.trino_client import TrinoClient
from teamlandkarte_mcp.models import Team
class _FakeCursor:
"""Minimal cursor double recording executes and returning canned rows."""
def __init__(self, rows: list[tuple] | None = None) -> None:
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
self._rows: list[tuple] = rows or []
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
if params is None:
params_tuple: tuple[object, ...] | None = None
elif isinstance(params, tuple):
params_tuple = params
else:
params_tuple = tuple(params)
self.executed.append((sql, params_tuple))
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return list(self._rows)
class _FakeCursorContext:
def __init__(self, cursor: _FakeCursor):
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _mk_client(cur: _FakeCursor) -> TrinoClient:
client = TrinoClient(_FakeConfig())
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
return client
def _norm_sql(sql: str) -> str:
return re.sub(r"\s+", " ", sql).strip()
def _assert_select_only(sql: str) -> None:
upper = sql.strip().upper()
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
assert kw not in upper, f"unexpected write keyword in SQL: {sql!r}"
# ---------------------------------------------------------------------------
def test_get_all_teams_empty_returns_empty_list() -> None:
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
result = client.get_all_teams()
assert result == []
assert len(cur.executed) == 1
sql = _norm_sql(cur.executed[0][0])
_assert_select_only(sql)
assert "INNER JOIN" in sql.upper()
assert "teamlandkarte_v_teams_latest" in sql
assert (
"teamlandkarte_v_teammeter_organizational_units_latest" in sql
)
assert "CAST(t.team_id AS VARCHAR) = CAST(ou.id AS VARCHAR)" in sql
assert "ORDER BY ou.name ASC, t.team_id ASC" in sql
assert "COALESCE(t.focus_name" in sql
assert "COALESCE(t.about_us" in sql
assert "COALESCE(t.offerings" in sql
assert "COALESCE(t.interests" in sql
def test_get_all_teams_aggregates_rows_into_team_instances() -> None:
cur = _FakeCursor(
rows=[
(
"T1",
"OU1",
"Alpha Team",
"Backend",
"About alpha",
"API offerings",
"Cloud interests",
),
(
"T2",
"OU2",
"Beta Team",
"",
"",
"",
"",
),
]
)
client = _mk_client(cur)
teams = client.get_all_teams()
assert len(teams) == 2
assert all(isinstance(t, Team) for t in teams)
assert teams[0].team_id == "T1"
assert teams[0].ouid == "OU1"
assert teams[0].team_name == "Alpha Team"
assert teams[0].focus_name == "Backend"
assert teams[0].about_us == "About alpha"
assert teams[0].offerings == "API offerings"
assert teams[0].interests == "Cloud interests"
assert teams[0].competences == []
assert teams[0].references == []
assert teams[1].team_id == "T2"
assert teams[1].team_name == "Beta Team"
assert teams[1].focus_name == ""
assert teams[1].about_us == ""
assert teams[1].offerings == ""
assert teams[1].interests == ""
def test_get_team_by_id_returns_none_when_no_row() -> None:
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
result = client.get_team_by_id("does-not-exist")
assert result is None
assert len(cur.executed) == 1
sql, params = cur.executed[0]
sql_norm = _norm_sql(sql)
_assert_select_only(sql_norm)
assert "CAST(t.team_id AS VARCHAR) = ?" in sql_norm
assert "t.ouid = ?" in sql_norm
assert params == ("does-not-exist", "does-not-exist")
def test_get_team_by_id_returns_none_for_blank_input() -> None:
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
assert client.get_team_by_id("") is None
assert client.get_team_by_id(" ") is None
# Blank input should short-circuit without issuing SQL.
assert cur.executed == []
def test_get_team_by_id_builds_team_from_row() -> None:
cur = _FakeCursor(
rows=[
(
"T42",
"OU42",
"Gamma Team",
"Data",
"About gamma",
"Analytics",
"AI interests",
)
]
)
client = _mk_client(cur)
team = client.get_team_by_id("T42")
assert isinstance(team, Team)
assert team.team_id == "T42"
assert team.ouid == "OU42"
assert team.team_name == "Gamma Team"
assert team.focus_name == "Data"
assert team.about_us == "About gamma"
assert team.offerings == "Analytics"
assert team.interests == "AI interests"
assert team.competences == []
assert team.references == []
def test_get_team_by_id_normalises_null_text_columns() -> None:
cur = _FakeCursor(
rows=[("T1", "OU1", "Solo Team", None, None, None, None)]
)
client = _mk_client(cur)
team = client.get_team_by_id("T1")
assert team is not None
# COALESCE handles NULL on the SQL side; the Python layer also
# defends against `None` defensively.
assert team.focus_name == ""
assert team.about_us == ""
assert team.offerings == ""
assert team.interests == ""
@@ -0,0 +1,233 @@
"""SQL-shape snapshot tests for `TrinoClient` team queries.
These tests focus exclusively on the *string shape* of the production
SQL emitted by the team-related methods. They complement the more
behaviour-oriented tests in:
* ``tests/test_trino_client_team_master_data.py``
* ``tests/test_trino_client_team_competences.py``
* ``tests/test_trino_client_team_references.py``
by acting as a focused snapshot guard that ``INNER JOIN``,
``ORDER BY``, ``COALESCE`` and the ``LEFT JOIN`` against
``teamlandkarte_v_partners_latest`` remain correctly formulated.
Each test calls a method on a ``TrinoClient`` whose ``_cursor`` is
swapped for a fake context yielding canned rows, then inspects
``cur.executed[0][0]`` (whitespace-normalised) for the expected
substrings. Returning canned rows lets every method run end-to-end
without touching a real database.
Validates: Requirements 2.1, 2.2, 3.1, 4.1, 4.2.
"""
from __future__ import annotations
import re
from teamlandkarte_mcp.database.trino_client import TrinoClient
class _FakeCursor:
"""Minimal cursor double recording executes and returning canned rows."""
def __init__(self, rows: list[tuple] | None = None) -> None:
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
self._rows: list[tuple] = rows or []
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
if params is None:
params_tuple: tuple[object, ...] | None = None
elif isinstance(params, tuple):
params_tuple = params
else:
params_tuple = tuple(params)
self.executed.append((sql, params_tuple))
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return list(self._rows)
class _FakeCursorContext:
def __init__(self, cursor: _FakeCursor):
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _mk_client(cur: _FakeCursor) -> TrinoClient:
client = TrinoClient(_FakeConfig())
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
return client
def _norm_sql(sql: str) -> str:
"""Collapse all whitespace runs into single spaces for stable matching."""
return re.sub(r"\s+", " ", sql).strip()
# ---------------------------------------------------------------------------
# get_all_teams master-data SQL shape
# ---------------------------------------------------------------------------
def test_get_all_teams_sql_shape_snapshot() -> None:
"""`get_all_teams` emits the documented master-data SQL.
Validates that the first executed query joins teams with the
organizational-units view via INNER JOIN, COALESCEs nullable text
columns and applies the deterministic ORDER BY documented in the
design.
"""
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
client.get_all_teams()
assert cur.executed, "expected at least one query to be executed"
sql = _norm_sql(cur.executed[0][0])
assert (
"INNER JOIN teamlandkarte_v_teammeter_organizational_units_latest"
in sql
)
assert "CAST(t.team_id AS VARCHAR) = CAST(ou.id AS VARCHAR)" in sql
assert "COALESCE(t.focus_name, '')" in sql
assert "COALESCE(t.about_us, '')" in sql
assert "COALESCE(t.offerings, '')" in sql
assert "COALESCE(t.interests, '')" in sql
assert "ORDER BY ou.name ASC, t.team_id ASC" in sql
# ---------------------------------------------------------------------------
# get_team_by_id same shape plus the team_id/ouid filter
# ---------------------------------------------------------------------------
def test_get_team_by_id_sql_shape_snapshot() -> None:
"""`get_team_by_id` reuses the master-data SQL plus a filter clause.
Validates that the same INNER JOIN / COALESCE / ORDER BY scaffolding
is in place and that the additional
``CAST(t.team_id AS VARCHAR) = ?`` and
``CAST(t.ouid AS VARCHAR) = ?`` filter terms are present.
"""
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
client.get_team_by_id("T1")
assert cur.executed, "expected the lookup query to be executed"
sql = _norm_sql(cur.executed[0][0])
# Same INNER JOIN scaffolding as get_all_teams.
assert (
"INNER JOIN teamlandkarte_v_teammeter_organizational_units_latest"
in sql
)
assert "CAST(t.team_id AS VARCHAR) = CAST(ou.id AS VARCHAR)" in sql
assert "COALESCE(t.focus_name, '')" in sql
assert "COALESCE(t.about_us, '')" in sql
assert "COALESCE(t.offerings, '')" in sql
assert "COALESCE(t.interests, '')" in sql
assert "ORDER BY ou.name ASC, t.team_id ASC" in sql
# Filter clause specific to the by-id lookup.
assert "CAST(t.team_id AS VARCHAR) = ?" in sql
assert "CAST(t.ouid AS VARCHAR) = ?" in sql
# ---------------------------------------------------------------------------
# get_team_competences / batch_get_team_competences competence join shape
# ---------------------------------------------------------------------------
def test_get_team_competences_sql_shape_snapshot() -> None:
"""`get_team_competences` joins competences and COALESCEs `top_competency`."""
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
client.get_team_competences("OU1")
assert cur.executed, "expected the competences query to be executed"
sql = _norm_sql(cur.executed[0][0])
assert "JOIN teamlandkarte_v_competences_latest" in sql
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql
assert "COALESCE(tc.top_competency, FALSE)" in sql
def test_batch_get_team_competences_sql_shape_snapshot() -> None:
"""`batch_get_team_competences` keeps the same join shape."""
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
client.batch_get_team_competences(["OU1", "OU2"])
assert cur.executed, "expected the batch competences query to be executed"
sql = _norm_sql(cur.executed[0][0])
assert "JOIN teamlandkarte_v_competences_latest" in sql
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql
assert "COALESCE(tc.top_competency, FALSE)" in sql
# ---------------------------------------------------------------------------
# get_team_references / batch_get_team_references partner LEFT JOIN shape
# ---------------------------------------------------------------------------
def test_get_team_references_sql_shape_snapshot() -> None:
"""`get_team_references` LEFT JOINs partners and COALESCEs `p.name`."""
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
client.get_team_references("OU1")
assert cur.executed, "expected the references query to be executed"
sql = _norm_sql(cur.executed[0][0])
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql
assert "CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql
assert "COALESCE(p.name, '')" in sql
def test_batch_get_team_references_sql_shape_snapshot() -> None:
"""`batch_get_team_references` keeps the same partner LEFT JOIN shape."""
cur = _FakeCursor(rows=[])
client = _mk_client(cur)
client.batch_get_team_references(["OU1", "OU2"])
assert cur.executed, "expected the batch references query to be executed"
sql = _norm_sql(cur.executed[0][0])
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql
assert "CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql
assert "COALESCE(p.name, '')" in sql
@@ -0,0 +1,338 @@
"""Unit tests for `TrinoClient.get_team_references` and
`batch_get_team_references`.
Covers SQL shape (LEFT JOIN on the partners view, COALESCE for
`partner_name`, deterministic `ORDER BY`), grouping by `ouid` in
the batch variant, whitespace-only `projects` filtering, empty
partner-name handling and empty-input handling.
"""
from __future__ import annotations
import re
import pytest
from teamlandkarte_mcp.database.trino_client import TrinoClient
class _FakeCursor:
"""Minimal cursor double recording executes and returning canned rows."""
def __init__(self, rows: list[tuple] | None = None) -> None:
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
self._rows: list[tuple] = rows or []
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
if params is None:
params_tuple: tuple[object, ...] | None = None
elif isinstance(params, tuple):
params_tuple = params
else:
params_tuple = tuple(params)
self.executed.append((sql, params_tuple))
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return list(self._rows)
class _FakeCursorContext:
def __init__(self, cursor: _FakeCursor):
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _mk_client(cur: _FakeCursor) -> TrinoClient:
client = TrinoClient(_FakeConfig())
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
return client
def _norm_sql(sql: str) -> str:
return re.sub(r"\s+", " ", sql).strip()
def _assert_select_only(sql: str) -> None:
upper = sql.strip().upper()
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
# ---------------------------------------------------------------------------
# Single-row variant
# ---------------------------------------------------------------------------
def test_get_team_references_returns_empty_for_blank_input() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
assert c.get_team_references("") == []
assert c.get_team_references(" ") == []
assert not cur.executed
def test_get_team_references_one_query_select_only_and_join_shape() -> None:
cur = _FakeCursor(
rows=[
("Project Apollo", "Acme"),
("Project Beta", ""),
]
)
c = _mk_client(cur)
out = c.get_team_references("OU1")
assert len(cur.executed) == 1
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_team_references_latest r" in sql_n
assert "LEFT JOIN teamlandkarte_v_partners_latest p" in sql_n
assert "ON CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql_n
assert "WHERE CAST(r.ouid AS VARCHAR) = ?" in sql_n
assert "COALESCE(p.name, '')" in sql_n
assert "ORDER BY partner_name ASC, r.projects ASC" in sql_n
assert params == ("OU1",)
assert out == [
{"partner_name": "Acme", "projects": "Project Apollo"},
{"partner_name": "", "projects": "Project Beta"},
]
def test_get_team_references_filters_whitespace_only_projects() -> None:
cur = _FakeCursor(
rows=[
(" ", "Acme"), # whitespace-only → drop
("", "Beta"), # empty → drop
("Project Real", "Gamma"),
]
)
c = _mk_client(cur)
out = c.get_team_references("OU1")
assert out == [
{"partner_name": "Gamma", "projects": "Project Real"},
]
def test_get_team_references_trims_projects() -> None:
cur = _FakeCursor(rows=[(" Project Apollo ", "Acme")])
c = _mk_client(cur)
out = c.get_team_references("OU1")
assert out == [
{"partner_name": "Acme", "projects": "Project Apollo"},
]
def test_get_team_references_keeps_entries_without_partner_name() -> None:
# The COALESCE is on the SQL side but the Python layer also
# defends against `None` for `partner_name` defensively.
cur = _FakeCursor(
rows=[
("Project Solo", None),
("Project Duo", ""),
]
)
c = _mk_client(cur)
out = c.get_team_references("OU1")
assert out == [
{"partner_name": "", "projects": "Project Solo"},
{"partner_name": "", "projects": "Project Duo"},
]
def test_get_team_references_skips_non_string_projects() -> None:
cur = _FakeCursor(
rows=[
(None, "Acme"), # non-string projects → drop
(123, "Beta"), # non-string projects → drop
("Project Real", "Gamma"),
]
)
c = _mk_client(cur)
out = c.get_team_references("OU1")
assert out == [
{"partner_name": "Gamma", "projects": "Project Real"},
]
def test_get_team_references_no_rows_returns_empty_list() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
assert c.get_team_references("OU1") == []
assert len(cur.executed) == 1
# ---------------------------------------------------------------------------
# Batch variant
# ---------------------------------------------------------------------------
def test_batch_get_team_references_empty_input_returns_empty_no_sql() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_team_references([])
assert out == {}
assert not cur.executed
def test_batch_get_team_references_groups_and_defaults_to_empty_list() -> None:
cur = _FakeCursor(
rows=[
("OU1", "Project Apollo", "Acme"),
("OU1", "Project Beta", ""),
("OU2", "Project Gamma", "Foo"),
]
)
c = _mk_client(cur)
out = c.batch_get_team_references(["OU1", "OU2", "OU3"])
assert len(cur.executed) == 1, "batch must use exactly one SELECT"
sql, params = cur.executed[0]
sql_n = _norm_sql(sql)
_assert_select_only(sql)
assert "FROM teamlandkarte_v_team_references_latest r" in sql_n
assert "LEFT JOIN teamlandkarte_v_partners_latest p" in sql_n
assert "ON CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql_n
assert "WHERE CAST(r.ouid AS VARCHAR) IN (?, ?, ?)" in sql_n
assert "COALESCE(p.name, '')" in sql_n
assert (
"ORDER BY r.ouid ASC, partner_name ASC, r.projects ASC" in sql_n
)
assert params == ("OU1", "OU2", "OU3")
assert out == {
"OU1": [
{"partner_name": "Acme", "projects": "Project Apollo"},
{"partner_name": "", "projects": "Project Beta"},
],
"OU2": [
{"partner_name": "Foo", "projects": "Project Gamma"},
],
"OU3": [],
}
def test_batch_get_team_references_filters_whitespace_only_projects() -> None:
cur = _FakeCursor(
rows=[
("OU1", " ", "Acme"), # whitespace-only → drop
("OU1", "Project Real", "Beta"),
("OU2", "", "Gamma"), # empty → drop
]
)
c = _mk_client(cur)
out = c.batch_get_team_references(["OU1", "OU2"])
assert out == {
"OU1": [{"partner_name": "Beta", "projects": "Project Real"}],
"OU2": [],
}
def test_batch_get_team_references_keeps_entries_without_partner_name() -> None:
cur = _FakeCursor(
rows=[
("OU1", "Project Solo", None),
("OU1", "Project Duo", ""),
]
)
c = _mk_client(cur)
out = c.batch_get_team_references(["OU1"])
assert out == {
"OU1": [
{"partner_name": "", "projects": "Project Solo"},
{"partner_name": "", "projects": "Project Duo"},
],
}
def test_batch_get_team_references_ignores_blank_ouid_inputs() -> None:
cur = _FakeCursor(rows=[])
c = _mk_client(cur)
out = c.batch_get_team_references(["", " "])
assert out == {"": [], " ": []}
assert not cur.executed
def test_batch_get_team_references_skips_rows_with_null_ouid() -> None:
cur = _FakeCursor(
rows=[
(None, "Project Ghost", "Acme"), # null ouid → drop
("OU1", "Project Real", "Beta"),
]
)
c = _mk_client(cur)
out = c.batch_get_team_references(["OU1"])
assert out == {
"OU1": [{"partner_name": "Beta", "projects": "Project Real"}],
}
# ---------------------------------------------------------------------------
# SELECT-only guard parametric coverage
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"method,args,rows",
[
("get_team_references", ("OU1",), [("Project Apollo", "Acme")]),
(
"batch_get_team_references",
(["OU1", "OU2"],),
[("OU1", "Project Apollo", "Acme")],
),
],
)
def test_team_reference_methods_emit_select_only(
method: str, args: tuple, rows: list[tuple]
) -> None:
cur = _FakeCursor(rows=rows)
c = _mk_client(cur)
getattr(c, method)(*args)
assert cur.executed, f"{method} should have executed a query"
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
sql, _params = cur.executed[0]
_assert_select_only(sql)
@@ -0,0 +1,153 @@
import re
import pytest
from teamlandkarte_mcp.database.trino_client import TrinoClient
class _FakeCursor:
def __init__(self) -> None:
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
if params is None:
params_tuple: tuple[object, ...] | None = None
elif isinstance(params, tuple):
params_tuple = params
else:
# Trino DB-API accepts sequences; tests only need tuple form.
params_tuple = tuple(params)
self.executed.append((sql, params_tuple))
def fetchone(self): # pragma: no cover
return (1,)
def fetchall(self): # pragma: no cover
return []
class _FakeCursorContext:
def __init__(self, cursor: _FakeCursor):
self._cursor = cursor
def __enter__(self) -> _FakeCursor:
return self._cursor
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
return None
class _FakeConfig:
host = "x"
port = 1
username = "u"
password = "p"
http_scheme = "http"
verify_ssl = False
catalog = "c"
schema = "s"
pool_size = 1
def _mk_client(cur: _FakeCursor) -> TrinoClient:
client = TrinoClient(_FakeConfig())
# Avoid connecting in tests; ensure all methods use our fake cursor.
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
return client
def _norm_sql(sql: str) -> str:
sql = re.sub(r"\s+", " ", sql).strip()
return sql
def test_get_recent_free_capacities_query_ordering_and_limit() -> None:
cur = _FakeCursor()
c = _mk_client(cur)
c.get_recent_free_capacities(limit=7)
assert cur.executed, "Expected query to execute"
sql, params = cur.executed[-1]
sql_n = _norm_sql(sql)
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
assert "WHERE cap.deletion_reason IS NULL" in sql_n
assert "ORDER BY cap.creation_date DESC" in sql_n
assert "LIMIT ?" in sql_n
assert params == (7,)
def test_get_capacity_by_id_query_filters_and_ordering() -> None:
cur = _FakeCursor()
c = _mk_client(cur)
c.get_capacity_by_id(123)
assert cur.executed
sql, params = cur.executed[-1]
sql_n = _norm_sql(sql)
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
assert "LEFT JOIN teamlandkarte_v_capacity_competences_latest" in sql_n
assert "LEFT JOIN teamlandkarte_v_competences_latest" in sql_n
assert "cap.deletion_reason IS NULL" in sql_n
assert "cap.id = ?" in sql_n
assert "ORDER BY comp.name" in sql_n
assert params == (123,)
def test_get_all_role_names_query_filters_and_ordering() -> None:
cur = _FakeCursor()
c = _mk_client(cur)
c.get_all_role_names()
assert cur.executed
sql, params = cur.executed[-1]
sql_n = _norm_sql(sql)
assert "FROM teamlandkarte_v_capacity_roles_latest" in sql_n
assert "WHERE active = true" in sql_n
assert "AND staffing_board_relevant = true" in sql_n
assert "ORDER BY name" in sql_n
assert params is None
def test_get_all_competence_names_query_filters_and_ordering() -> None:
cur = _FakeCursor()
c = _mk_client(cur)
c.get_all_competence_names()
assert cur.executed
sql, params = cur.executed[-1]
sql_n = _norm_sql(sql)
assert "FROM beschaffungstool_kmp_skill_latest" in sql_n
assert "WHERE skillname__c IS NOT NULL" in sql_n
assert "ORDER BY skillname__c" in sql_n
assert params is None
@pytest.mark.parametrize(
"method,args",
[
("get_recent_free_capacities", (1,)),
("get_capacity_by_id", ("1",)),
("get_all_role_names", ()),
("get_all_competence_names", ()),
],
)
def test_phase2_queries_are_select_only(method: str, args: tuple[object, ...]) -> None:
cur = _FakeCursor()
c = _mk_client(cur)
getattr(c, method)(*args)
assert cur.executed
sql, _params = cur.executed[-1]
assert sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith(
"WITH"
)