Files
Orchestrator/bahn/teamlandkarte-mcp/tests/test_team_master_data_pbt.py
T
ankn a5f8fb49ab 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.
2026-06-30 20:39:52 +02:00

416 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
)