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,431 @@
|
||||
# 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user