# Feature: team-profile-matching, Property 14: Schema-Verifikation deckt fehlende Spalten in den vier neuen Views auf """Property-based test for schema verification of the four new team views. Validates Property 14 from the team-profile-matching design: *Für jede* Mock-DB-Antwort, die in einer der vier Views (``teamlandkarte_v_teams_latest``, ``teamlandkarte_v_teammeter_organizational_units_latest``, ``teamlandkarte_v_teammeter_team_competences_latest``, ``teamlandkarte_v_team_references_latest``) eine erwartete Spalte weglässt, liefert ``verify_required_columns(...)`` für die in ``mcp_server.build_server`` konfigurierte ``schema_expected``-Erweiterung einen ``SchemaIssue`` mit dem betroffenen Tabellennamen und enthält die fehlende Spalte in ``message``. Wenn alle erwarteten Spalten vorhanden sind, ist das Issue-Set für diese vier Views leer. The Hypothesis strategy generates, for a randomly chosen view, a non-empty subset of its expected columns to drop. The stub DB then returns ``full_set - dropped`` for that view (and the full sets for the other three views). The test asserts that ``verify_required_columns``: 1. Returns at least one ``SchemaIssue`` whose ``table`` equals the chosen view. 2. The issue's ``message`` mentions every dropped column (lowercased, per the verifier's normalization). 3. No issues are reported for the other three views (which received their full column sets). The positive case is covered by an additional non-property test that feeds the full column sets and expects an empty issue list. **Validates: Requirements 12.1, 12.2, 12.3, 12.4** """ from __future__ import annotations from typing import Iterable from hypothesis import given, settings from hypothesis import strategies as st from teamlandkarte_mcp.database.schema_verifier import ( SchemaIssue, verify_required_columns, ) # --------------------------------------------------------------------------- # Source of truth: the four new views and their expected columns. # Mirrors the ``schema_expected`` extension configured in # ``mcp_server.build_server`` (see design.md, "Schema-Verifikation beim # Start"). Keeping this map local keeps the test independent of # ``build_server`` runtime wiring (which requires a full config + DB). # --------------------------------------------------------------------------- _EXPECTED_COLUMNS: dict[str, frozenset[str]] = { "teamlandkarte_v_teams_latest": frozenset( {"team_id", "ouid", "about_us", "offerings", "interests", "focus_name"} ), "teamlandkarte_v_teammeter_organizational_units_latest": frozenset( {"id", "name"} ), "teamlandkarte_v_teammeter_team_competences_latest": frozenset( {"ouid", "competence_id", "top_competency"} ), "teamlandkarte_v_team_references_latest": frozenset( {"ouid", "partner_id", "projects"} ), } _VIEW_NAMES: tuple[str, ...] = tuple(_EXPECTED_COLUMNS.keys()) # --------------------------------------------------------------------------- # Test double # --------------------------------------------------------------------------- class _StubDB: """Stub DB exposing ``get_table_columns`` for a fixed mapping. The verifier protocol only requires this single method (see ``schema_verifier.SchemaIntrospector``), so this stub is sufficient to exercise the full code path without touching a real database. """ def __init__(self, mapping: dict[str, list[str]]): self._mapping = mapping def get_table_columns(self, table: str) -> list[str]: # ``verify_required_columns`` treats unknown tables as "no columns". return list(self._mapping.get(table, [])) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _full_columns_for_all_views() -> dict[str, list[str]]: """Return the full column lists for every configured view.""" return {view: sorted(cols) for view, cols in _EXPECTED_COLUMNS.items()} def _expected_map() -> dict[str, set[str]]: """Return a fresh ``expected`` map matching the verifier's signature. The verifier expects ``dict[str, set[str]]``; the canonical map uses ``frozenset`` for immutability, so we materialize plain ``set``s here. """ return {view: set(cols) for view, cols in _EXPECTED_COLUMNS.items()} def _issues_for_table( issues: Iterable[SchemaIssue], table: str ) -> list[SchemaIssue]: return [issue for issue in issues if issue.table == table] # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- def _drop_subset_strategy() -> st.SearchStrategy[tuple[str, frozenset[str]]]: """Strategy for ``(view, columns_to_drop)`` pairs. Picks a view from the four configured views and a non-empty subset of its expected columns to "drop" (i.e. omit from the stub DB's response for that view). The subset is non-empty so the property's precondition ("at least one expected column is omitted") always holds. """ def _for_view(view: str) -> st.SearchStrategy[tuple[str, frozenset[str]]]: cols = sorted(_EXPECTED_COLUMNS[view]) # Subset of size in [1, len(cols)]: covers the full powerset # minus the empty set, which corresponds to the design's # "Powerset ... minus a randomly chosen column". return st.lists( st.sampled_from(cols), min_size=1, max_size=len(cols), unique=True, ).map(lambda picks: (view, frozenset(picks))) return st.sampled_from(_VIEW_NAMES).flatmap(_for_view) # --------------------------------------------------------------------------- # Property: dropping any subset of columns yields an issue per dropped column # --------------------------------------------------------------------------- @settings(max_examples=100, deadline=None) @given(case=_drop_subset_strategy()) def test_missing_columns_in_any_of_the_four_views_produces_schema_issue( case: tuple[str, frozenset[str]], ) -> None: """Property 14 - negative direction. For every (view, non-empty drop-subset), the verifier emits a ``SchemaIssue`` whose ``table`` is the view and whose ``message`` mentions every dropped column. Other views (with full column sets) must not produce any issues. """ view, dropped = case full_cols = _EXPECTED_COLUMNS[view] remaining = sorted(full_cols - dropped) db_mapping = _full_columns_for_all_views() db_mapping[view] = remaining db = _StubDB(db_mapping) issues = verify_required_columns(db=db, expected=_expected_map()) # 1) The affected view is reported exactly once. affected = _issues_for_table(issues, view) assert len(affected) == 1, ( f"Expected exactly one SchemaIssue for {view!r}, got {affected!r}" ) # 2) Each dropped column (normalized to lowercase, like the verifier # does internally) appears in the issue message. message = affected[0].message assert message.startswith("missing columns: "), message for col in dropped: assert col.lower() in message, ( f"Dropped column {col!r} not mentioned in issue message {message!r}" ) # 3) The other three views must not produce any issues, since they # were given their full expected column sets. for other in _VIEW_NAMES: if other == view: continue assert _issues_for_table(issues, other) == [], ( f"Unexpected issue for unaffected view {other!r}: " f"{_issues_for_table(issues, other)!r}" ) # --------------------------------------------------------------------------- # Positive case: full column sets -> no issues for the four views # --------------------------------------------------------------------------- def test_full_column_sets_for_all_four_views_yield_no_issues() -> None: """Property 14 - positive direction. When every view exposes its full expected column set, the verifier must return zero issues for the four configured team views. """ db = _StubDB(_full_columns_for_all_views()) issues = verify_required_columns(db=db, expected=_expected_map()) issues_for_team_views = [ issue for issue in issues if issue.table in _EXPECTED_COLUMNS ] assert issues_for_team_views == [] # --------------------------------------------------------------------------- # Sanity check: the configured map in ``build_server`` matches the # canonical map used by this test. This is not part of Property 14 but # guards against silent drift between the schema verifier wiring and # the test's source of truth. # --------------------------------------------------------------------------- def test_canonical_map_matches_mcp_server_configuration() -> None: """The four-view subset of ``schema_expected`` matches this test.""" import inspect from teamlandkarte_mcp import mcp_server source = inspect.getsource(mcp_server.build_server) for view, cols in _EXPECTED_COLUMNS.items(): assert view in source, ( f"View {view!r} missing from mcp_server.build_server" ) for col in cols: assert f'"{col}"' in source, ( f"Column {col!r} for view {view!r} missing " f"from mcp_server.build_server" )