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.
432 lines
15 KiB
Python
432 lines
15 KiB
Python
# 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}"
|
|
)
|