"""Unit tests for `TrinoClient.get_team_competences` and `batch_get_team_competences`. Covers SQL shape (INNER JOIN on the competences view, COALESCE for `top_competency`, deterministic `ORDER BY`), grouping by `ouid` in the batch variant, NULL-name filtering 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_competences_returns_empty_for_blank_input() -> None: cur = _FakeCursor(rows=[]) c = _mk_client(cur) assert c.get_team_competences("") == [] assert c.get_team_competences(" ") == [] assert cur.executed == [] def test_get_team_competences_one_query_select_only_and_join_shape() -> None: cur = _FakeCursor( rows=[ ("Python", True), ("Cloud", False), ] ) c = _mk_client(cur) out = c.get_team_competences("OU1") assert len(cur.executed) == 1 sql, params = cur.executed[0] sql_n = _norm_sql(sql) _assert_select_only(sql) assert ( "FROM teamlandkarte_v_teammeter_team_competences_latest tc" in sql_n ) assert "JOIN teamlandkarte_v_competences_latest c" in sql_n assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql_n assert "WHERE CAST(tc.ouid AS VARCHAR) = ?" in sql_n assert "COALESCE(tc.top_competency, FALSE)" in sql_n # Order: top first, then name asc assert "ORDER BY" in sql_n assert "CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END" in sql_n assert "c.name ASC" in sql_n assert params == ("OU1",) assert out == [ {"name": "Python", "top_competency": True}, {"name": "Cloud", "top_competency": False}, ] def test_get_team_competences_normalises_null_top_competency() -> None: # SQL-side COALESCE turns NULL into FALSE, but the Python layer also # defends against `None` defensively via bool(...). cur = _FakeCursor(rows=[("Python", None), ("Cloud", 1)]) c = _mk_client(cur) out = c.get_team_competences("OU1") assert out == [ {"name": "Python", "top_competency": False}, {"name": "Cloud", "top_competency": True}, ] def test_get_team_competences_filters_rows_without_resolvable_name() -> None: cur = _FakeCursor( rows=[ (None, True), # join produced no name → drop ("", False), # empty name → drop ("Python", True), ] ) c = _mk_client(cur) assert c.get_team_competences("OU1") == [ {"name": "Python", "top_competency": True}, ] def test_get_team_competences_no_rows_returns_empty_list() -> None: cur = _FakeCursor(rows=[]) c = _mk_client(cur) assert c.get_team_competences("OU1") == [] assert len(cur.executed) == 1 # --------------------------------------------------------------------------- # Batch variant # --------------------------------------------------------------------------- def test_batch_get_team_competences_empty_input_returns_empty_no_sql() -> None: cur = _FakeCursor(rows=[]) c = _mk_client(cur) out = c.batch_get_team_competences([]) assert out == {} assert cur.executed == [] def test_batch_get_team_competences_groups_and_defaults_to_empty_list() -> None: cur = _FakeCursor( rows=[ ("OU1", "Python", True), ("OU1", "Cloud", False), ("OU2", "Architecture", True), ] ) c = _mk_client(cur) out = c.batch_get_team_competences(["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_teammeter_team_competences_latest tc" in sql_n ) assert "JOIN teamlandkarte_v_competences_latest c" in sql_n assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql_n assert "WHERE CAST(tc.ouid AS VARCHAR) IN (?, ?, ?)" in sql_n assert "ORDER BY tc.ouid ASC" in sql_n assert "CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END" in sql_n assert "c.name ASC" in sql_n assert params == ("OU1", "OU2", "OU3") assert out == { "OU1": [ {"name": "Python", "top_competency": True}, {"name": "Cloud", "top_competency": False}, ], "OU2": [ {"name": "Architecture", "top_competency": True}, ], "OU3": [], } def test_batch_get_team_competences_filters_unresolved_competence_names() -> None: cur = _FakeCursor( rows=[ ("OU1", None, True), # no resolvable name → drop ("OU1", "Python", True), ("OU2", "", False), # empty name → drop ] ) c = _mk_client(cur) out = c.batch_get_team_competences(["OU1", "OU2"]) assert out == { "OU1": [{"name": "Python", "top_competency": True}], "OU2": [], } def test_batch_get_team_competences_normalises_null_top_competency() -> None: cur = _FakeCursor( rows=[ ("OU1", "Python", None), ("OU1", "Cloud", 1), ] ) c = _mk_client(cur) out = c.batch_get_team_competences(["OU1"]) assert out == { "OU1": [ {"name": "Python", "top_competency": False}, {"name": "Cloud", "top_competency": True}, ], } def test_batch_get_team_competences_ignores_blank_ouid_inputs() -> None: cur = _FakeCursor(rows=[]) c = _mk_client(cur) out = c.batch_get_team_competences(["", " "]) assert out == {"": [], " ": []} assert cur.executed == [] # --------------------------------------------------------------------------- # SELECT-only guard parametric coverage # --------------------------------------------------------------------------- @pytest.mark.parametrize( "method,args,rows", [ ("get_team_competences", ("OU1",), [("Python", True)]), ( "batch_get_team_competences", (["OU1", "OU2"],), [("OU1", "Python", True)], ), ], ) def test_team_competence_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)