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,338 @@
|
||||
"""Unit tests for `TrinoClient.get_team_references` and
|
||||
`batch_get_team_references`.
|
||||
|
||||
Covers SQL shape (LEFT JOIN on the partners view, COALESCE for
|
||||
`partner_name`, deterministic `ORDER BY`), grouping by `ouid` in
|
||||
the batch variant, whitespace-only `projects` filtering, empty
|
||||
partner-name handling and empty-input handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Minimal cursor double recording executes and returning canned rows."""
|
||||
|
||||
def __init__(self, rows: list[tuple] | None = None) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
self._rows: list[tuple] = rows or []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
return re.sub(r"\s+", " ", sql).strip()
|
||||
|
||||
|
||||
def _assert_select_only(sql: str) -> None:
|
||||
upper = sql.strip().upper()
|
||||
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
|
||||
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
|
||||
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-row variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_references_returns_empty_for_blank_input() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_references("") == []
|
||||
assert c.get_team_references(" ") == []
|
||||
assert not cur.executed
|
||||
|
||||
|
||||
def test_get_team_references_one_query_select_only_and_join_shape() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("Project Apollo", "Acme"),
|
||||
("Project Beta", ""),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_team_references_latest r" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest p" in sql_n
|
||||
assert "ON CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql_n
|
||||
assert "WHERE CAST(r.ouid AS VARCHAR) = ?" in sql_n
|
||||
assert "COALESCE(p.name, '')" in sql_n
|
||||
assert "ORDER BY partner_name ASC, r.projects ASC" in sql_n
|
||||
assert params == ("OU1",)
|
||||
assert out == [
|
||||
{"partner_name": "Acme", "projects": "Project Apollo"},
|
||||
{"partner_name": "", "projects": "Project Beta"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_filters_whitespace_only_projects() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(" ", "Acme"), # whitespace-only → drop
|
||||
("", "Beta"), # empty → drop
|
||||
("Project Real", "Gamma"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "Gamma", "projects": "Project Real"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_trims_projects() -> None:
|
||||
cur = _FakeCursor(rows=[(" Project Apollo ", "Acme")])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "Acme", "projects": "Project Apollo"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_keeps_entries_without_partner_name() -> None:
|
||||
# The COALESCE is on the SQL side but the Python layer also
|
||||
# defends against `None` for `partner_name` defensively.
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("Project Solo", None),
|
||||
("Project Duo", ""),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "", "projects": "Project Solo"},
|
||||
{"partner_name": "", "projects": "Project Duo"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_skips_non_string_projects() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(None, "Acme"), # non-string projects → drop
|
||||
(123, "Beta"), # non-string projects → drop
|
||||
("Project Real", "Gamma"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "Gamma", "projects": "Project Real"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_no_rows_returns_empty_list() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_references("OU1") == []
|
||||
assert len(cur.executed) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batch_get_team_references_empty_input_returns_empty_no_sql() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references([])
|
||||
|
||||
assert out == {}
|
||||
assert not cur.executed
|
||||
|
||||
|
||||
def test_batch_get_team_references_groups_and_defaults_to_empty_list() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", "Project Apollo", "Acme"),
|
||||
("OU1", "Project Beta", ""),
|
||||
("OU2", "Project Gamma", "Foo"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1", "OU2", "OU3"])
|
||||
|
||||
assert len(cur.executed) == 1, "batch must use exactly one SELECT"
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_team_references_latest r" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest p" in sql_n
|
||||
assert "ON CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql_n
|
||||
assert "WHERE CAST(r.ouid AS VARCHAR) IN (?, ?, ?)" in sql_n
|
||||
assert "COALESCE(p.name, '')" in sql_n
|
||||
assert (
|
||||
"ORDER BY r.ouid ASC, partner_name ASC, r.projects ASC" in sql_n
|
||||
)
|
||||
assert params == ("OU1", "OU2", "OU3")
|
||||
assert out == {
|
||||
"OU1": [
|
||||
{"partner_name": "Acme", "projects": "Project Apollo"},
|
||||
{"partner_name": "", "projects": "Project Beta"},
|
||||
],
|
||||
"OU2": [
|
||||
{"partner_name": "Foo", "projects": "Project Gamma"},
|
||||
],
|
||||
"OU3": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_references_filters_whitespace_only_projects() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", " ", "Acme"), # whitespace-only → drop
|
||||
("OU1", "Project Real", "Beta"),
|
||||
("OU2", "", "Gamma"), # empty → drop
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1", "OU2"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [{"partner_name": "Beta", "projects": "Project Real"}],
|
||||
"OU2": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_references_keeps_entries_without_partner_name() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", "Project Solo", None),
|
||||
("OU1", "Project Duo", ""),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [
|
||||
{"partner_name": "", "projects": "Project Solo"},
|
||||
{"partner_name": "", "projects": "Project Duo"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_references_ignores_blank_ouid_inputs() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["", " "])
|
||||
|
||||
assert out == {"": [], " ": []}
|
||||
assert not cur.executed
|
||||
|
||||
|
||||
def test_batch_get_team_references_skips_rows_with_null_ouid() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(None, "Project Ghost", "Acme"), # null ouid → drop
|
||||
("OU1", "Project Real", "Beta"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [{"partner_name": "Beta", "projects": "Project Real"}],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SELECT-only guard parametric coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args,rows",
|
||||
[
|
||||
("get_team_references", ("OU1",), [("Project Apollo", "Acme")]),
|
||||
(
|
||||
"batch_get_team_references",
|
||||
(["OU1", "OU2"],),
|
||||
[("OU1", "Project Apollo", "Acme")],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_team_reference_methods_emit_select_only(
|
||||
method: str, args: tuple, rows: list[tuple]
|
||||
) -> None:
|
||||
cur = _FakeCursor(rows=rows)
|
||||
c = _mk_client(cur)
|
||||
|
||||
getattr(c, method)(*args)
|
||||
|
||||
assert cur.executed, f"{method} should have executed a query"
|
||||
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
|
||||
sql, _params = cur.executed[0]
|
||||
_assert_select_only(sql)
|
||||
Reference in New Issue
Block a user