# 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(, '')`` 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(, '')`` 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" )