# 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}" )