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:
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user