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