diff --git a/backend/src/services/dashboard_testing/filters.py b/backend/src/services/dashboard_testing/filters.py new file mode 100644 index 000000000..03d441cc7 --- /dev/null +++ b/backend/src/services/dashboard_testing/filters.py @@ -0,0 +1,105 @@ +#region BaselineEngine.Filters.Normalize [C:4] [TYPE Function] [SEMANTICS baseline,filter,canonical,scope] +# @defgroup BaselineEngine Filter normalization — canonical typed filter identity with deterministic hash. +# @LAYER Service +# @RELATION DEPENDS_ON -> [DashboardTesting.Schemas] +# @RELATION DEPENDS_ON -> [BaselineEngine.Fingerprints] + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from src.schemas.dashboard_testing import ( + DashboardQueryModel, + NormalizedFilter, + NormalizedFilterContext, + FilterValue, +) + + +def _canonical_filter_value(value: FilterValue) -> dict[str, Any]: + """Serialize filter value to a canonical JSON-safe dict for hashing.""" + d: dict[str, Any] = {} + if value.from_ is not None: + d["from"] = value.from_ + if value.to is not None: + d["to"] = value.to + if value.value is not None: + d["value"] = value.value + if value.values is not None: + d["values"] = sorted(value.values) # sort for deterministic hash + if not value.inclusive: + d["inclusive"] = False + return d + + +def _compute_filters_hash(filters: list[NormalizedFilter]) -> str: + """Compute deterministic SHA-256 of the canonical filter JSON.""" + # Build canonical structure: sorted by (dataset_id, column, operator, filter_id) + records = [] + for f in sorted(filters, key=lambda x: (x.dataset_id, x.column, x.operator, x.filter_id)): + records.append({ + "filter_id": f.filter_id, + "dataset_id": f.dataset_id, + "column": f.column, + "operator": f.operator, + "value": _canonical_filter_value(f.value), + "target_chart_ids": sorted(f.target_chart_ids), + }) + canonical = json.dumps(records, sort_keys=True, default=str) + return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest() + + +def normalize_filters( + filter_inputs: list[NormalizedFilter], + query_model: DashboardQueryModel, +) -> NormalizedFilterContext: + """ + Validate dashboard filter values against metadata/scope and produce + canonical typed filter identity. + + @PRE Query model is authoritative and fingerprint-valid. + @POST Filters are typed, sorted, scoped, and hashed. + @SIDE_EFFECT None. + @DATA_CONTRACT FilterInput[] + DashboardQueryModel -> NormalizedFilterContext + """ + valid_chart_ids = {ch.chart_id for ch in query_model.charts} + valid_filter_ids = {nf.filter_id for nf in query_model.native_filters} + + validated: list[NormalizedFilter] = [] + + for fi in filter_inputs: + # Validate filter exists in query model + if fi.filter_id not in valid_filter_ids: + raise ValueError( + f"Filter '{fi.filter_id}' not found in query model native filters" + ) + + # Validate target charts exist + for tcid in fi.target_chart_ids: + if tcid not in valid_chart_ids: + raise ValueError( + f"Target chart {tcid} for filter '{fi.filter_id}' not in query model charts" + ) + + # Validate dataset_id exists in model + dataset_ids = {ds.dataset_id for ds in query_model.datasets} + if fi.dataset_id not in dataset_ids: + raise ValueError( + f"Dataset {fi.dataset_id} for filter '{fi.filter_id}' not in query model datasets" + ) + + validated.append(fi) + + # Sort in canonical order: dataset_id, column, operator, filter_id + validated.sort(key=lambda x: (x.dataset_id, x.column, x.operator, x.filter_id)) + + filters_hash = _compute_filters_hash(validated) + + return NormalizedFilterContext( + schema_version=1, + filters=validated, + filters_hash=filters_hash, + ) +# #endregion BaselineEngine.Filters.Normalize diff --git a/backend/src/services/dashboard_testing/fingerprints.py b/backend/src/services/dashboard_testing/fingerprints.py new file mode 100644 index 000000000..245628ed5 --- /dev/null +++ b/backend/src/services/dashboard_testing/fingerprints.py @@ -0,0 +1,43 @@ +#region BaselineEngine.Fingerprints [C:3] [TYPE Module] [SEMANTICS baseline,fingerprint,hash,deterministic] +# @defgroup BaselineEngine Fingerprint utilities — deterministic hashing for query models, filters, and metadata. +# @LAYER Service + +from __future__ import annotations + +import hashlib +import json +from typing import Any + + +def compute_sha256(data: str | bytes | dict | list) -> str: + """ + Compute a deterministic SHA-256 hash of the input. + + For dicts/lists, JSON is canonicalized via sort_keys before hashing. + Returns a hex digest string. + + @PRE Input is JSON-serializable. + @POST Returns lowercase hex string. + @SIDE_EFFECT None. + """ + if isinstance(data, (dict, list)): + raw = json.dumps(data, sort_keys=True, default=str).encode("utf-8") + elif isinstance(data, str): + raw = data.encode("utf-8") + else: + raw = bytes(data) + return hashlib.sha256(raw).hexdigest() + + +def compute_query_model_fingerprint(model_dict: dict[str, Any]) -> str: + """ + Compute a stable fingerprint for a query model dict. + + Excludes the fingerprint field itself to avoid recursion. + + @PRE model_dict is a JSON-safe dict from DashboardQueryModel.model_dump(). + @POST Returns 'sha256:' fingerprint. + """ + stripped = {k: v for k, v in model_dict.items() if k != "query_model_fingerprint"} + return "sha256:" + compute_sha256(stripped) +# #endregion BaselineEngine.Fingerprints diff --git a/backend/src/services/dashboard_testing/query_model.py b/backend/src/services/dashboard_testing/query_model.py new file mode 100644 index 000000000..350c83611 --- /dev/null +++ b/backend/src/services/dashboard_testing/query_model.py @@ -0,0 +1,267 @@ +#region BaselineEngine.QueryModel.Inspect [C:5] [TYPE Function] [SEMANTICS baseline,inspection,dashboard,metadata] +# @defgroup BaselineEngine Dashboard query model inspection — extracts structured metadata from Superset. +# @LAYER Service +# @RELATION DEPENDS_ON -> [SupersetClient] +# @RELATION DEPENDS_ON -> [DashboardTesting.Schemas] + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from src.core.superset_client import SupersetClient +from src.core.utils.network import SupersetAPIError +from src.schemas.dashboard_testing import ( + DashboardQueryModel, ChartQueryModel, DatasetQueryModel, ColumnInfo, + NativeFilterModel, FilterTarget, MetricDescriptor, ColumnRef, + DashboardCapabilities, Warning, VizType, +) + + +def _safe_json_load(raw: Any) -> dict: + """Parse JSON from string or return empty dict on failure.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + return {} + + +def _parse_viz_type(raw: str | None) -> VizType: + """Map Superset viz_type string to our enum.""" + if not raw: + return VizType.OTHER + mapping: dict[str, VizType] = { + "table": VizType.TABLE, "bar": VizType.BAR, "line": VizType.LINE, + "pie": VizType.PIE, "big_number": VizType.BIG_NUMBER, + "big_number_total": VizType.BIG_NUMBER_TOTAL, + "filter_box": VizType.FILTER_BOX, + } + return mapping.get(raw, VizType.OTHER) + + +def _compute_fingerprint(model_dict: dict) -> str: + """Compute deterministic SHA-256 fingerprint of the query model.""" + canonical = json.dumps(model_dict, sort_keys=True, default=str) + return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest() + + +async def inspect_dashboard_query_model( + client: SupersetClient, + environment_id: str, + dashboard_id: int, +) -> DashboardQueryModel: + """ + Build a deterministic query model from authoritative Superset metadata. + + @PRE Environment and dashboard are readable by actor. + @POST Returns stable sorted model, per-resource warnings, capabilities, and fingerprint. + @SIDE_EFFECT Async GET calls through SupersetClient. + @DATA_CONTRACT InspectRequest -> DashboardQueryModel + """ + warnings: list[Warning] = [] + + # 1. Fetch dashboard metadata + try: + dash_response = await client.get_dashboard(dashboard_id) + dash_data = dash_response.get("result", dash_response) + except SupersetAPIError as e: + return DashboardQueryModel( + environment_id=environment_id, dashboard_id=dashboard_id, + title="[Fetch Failed]", + warnings=[Warning(source="inspection", resource=str(dashboard_id), + code="DASHBOARD_FETCH_FAILED", detail=str(e))], + query_model_fingerprint="sha256:error", + ) + + title = dash_data.get("dashboard_title", f"Dashboard {dashboard_id}") + slug = dash_data.get("slug") + json_metadata = _safe_json_load(dash_data.get("json_metadata", "{}")) + position_json = _safe_json_load(dash_data.get("position_json", "{}")) + + # 2. Extract native filters from json_metadata + native_filters: list[NativeFilterModel] = [] + raw_filters = json_metadata.get("native_filter_configuration", []) + for rf in raw_filters: + targets = [ + FilterTarget(chart_id=0, dataset_id=t.get("datasetId", 0)) + for t in rf.get("targets", []) + ] + filter_type = rf.get("filterType", "filter_select") + type_map = { + "filter_date": "DATE", "filter_time": "TIME", + "filter_time_grain": "TIME_GRAIN", "filter_range": "NUMERIC", + "filter_select": "STRING", + } + native_filters.append(NativeFilterModel( + filter_id=rf.get("id", ""), filter_type="NATIVE_FILTER", + name=rf.get("name", rf.get("id", "")), + column=rf.get("targets", [{}])[0].get("column", {}).get("name", ""), + dataset_id=rf.get("targets", [{}])[0].get("datasetId", 0), + type=type_map.get(filter_type, "STRING"), + targets=targets, + )) + + # 3. Extract chart IDs from position JSON + chart_ids: set[int] = set() + position_chart_meta: dict[int, dict] = {} + for key, value in position_json.items(): + if isinstance(value, dict): + meta = value.get("meta", {}) + cid = meta.get("chartId") + if cid is not None: + chart_ids.add(int(cid)) + position_chart_meta[int(cid)] = meta + + # 4. Fetch chart metadata through dashboard/charts endpoint for form_data + charts: list[ChartQueryModel] = [] + try: + charts_data = await client.get_dashboard_charts(dashboard_id) + except SupersetAPIError: + charts_data = [] + + for chart_obj in charts_data: + cid = chart_obj.get("id") + if cid is None: + continue + cid = int(cid) + chart_ids.discard(cid) # mark as processed + + form_data = _safe_json_load(chart_obj.get("form_data", "{}")) + params_str = chart_obj.get("params") + params = _safe_json_load(params_str) if params_str else {} + + metrics: list[MetricDescriptor] = [] + raw_metrics = params.get("metrics") or form_data.get("metrics", []) + for rm in raw_metrics: + if isinstance(rm, str): + metrics.append(MetricDescriptor( + metric_name=rm, label=rm, expression_type="SIMPLE")) + elif isinstance(rm, dict): + metrics.append(MetricDescriptor( + metric_name=rm.get("metric_name", rm.get("label", "")), + label=rm.get("label", rm.get("metric_name", "")), + expression_type=rm.get("expressionType", "SIMPLE"), + column=ColumnRef(column_name=rm.get("column", {}).get("column_name", ""), + type=rm.get("column", {}).get("type")) + if rm.get("column") else None, + aggregate=rm.get("aggregate"), + sql_expression=rm.get("sqlExpression"))) + + groupby = params.get("groupby") or form_data.get("groupby", []) + + charts.append(ChartQueryModel( + chart_id=cid, + chart_uuid=chart_obj.get("uuid"), + slice_name=chart_obj.get("slice_name", f"Chart {cid}"), + viz_type=_parse_viz_type(form_data.get("viz_type") or chart_obj.get("viz_type")), + dataset_id=chart_obj.get("datasource_id", 0), + dataset_uuid=None, + dataset_name=chart_obj.get("datasource_name_text", ""), + metrics=metrics, + group_by_columns=list(groupby) if groupby else [], + applied_filter_ids=[], + excluded_filter_ids=[], + execution_capable=True, + )) + + # Remaining charts from position_json that weren't in charts endpoint + for cid in sorted(chart_ids): + try: + chart_detail = await client.get_chart(cid) + chart_data = chart_detail.get("result", chart_detail) + params = _safe_json_load(chart_data.get("params", "{}")) + + metrics = [] + for rm in params.get("metrics", []): + m_name = rm if isinstance(rm, str) else rm.get("metric_name", rm.get("label", "")) + metrics.append(MetricDescriptor( + metric_name=m_name, label=m_name, expression_type="SIMPLE")) + + charts.append(ChartQueryModel( + chart_id=cid, + chart_uuid=chart_data.get("uuid"), + slice_name=chart_data.get("slice_name", f"Chart {cid}"), + viz_type=_parse_viz_type(chart_data.get("viz_type")), + dataset_id=chart_data.get("datasource_id", 0), + dataset_name=chart_data.get("datasource_name_text", ""), + metrics=metrics, + group_by_columns=list(params.get("groupby", [])), + execution_capable=True, + )) + except SupersetAPIError as e: + warnings.append(Warning(source="inspection", resource=str(cid), + code="INACCESSIBLE_CHART", detail=str(e))) + charts.append(ChartQueryModel( + chart_id=cid, slice_name=f"Chart {cid} (inaccessible)", + viz_type=VizType.OTHER, dataset_id=0, + dataset_name="[inaccessible]", execution_capable=False)) + + # Sort charts by id + charts.sort(key=lambda c: c.chart_id) + + # 5. Fetch dataset metadata + datasets: list[DatasetQueryModel] = [] + dataset_ids: set[int] = {ch.dataset_id for ch in charts if ch.dataset_id > 0} + + try: + datasets_data = await client.get_dashboard_datasets(dashboard_id) + except SupersetAPIError: + datasets_data = [] + + for ds in datasets_data: + did = ds.get("id", 0) + dataset_ids.discard(did) + + columns = [ + ColumnInfo(column_name=col.get("column_name", ""), + type=col.get("type", "STRING"), + groupby=col.get("groupby", False), + filterable=col.get("filterable", False)) + for col in ds.get("columns", []) + ] + + ds_metrics = [ + MetricDescriptor( + metric_name=m.get("metric_name", ""), + label=m.get("verbose_name", m.get("metric_name", "")), + expression_type="SIMPLE", + column=ColumnRef(column_name=m.get("column", {}).get("column_name", ""))) + for m in ds.get("metrics", []) + ] + + datasets.append(DatasetQueryModel( + dataset_id=did, dataset_uuid=ds.get("uuid"), + dataset_name=ds.get("table_name", f"Dataset {did}"), + columns=columns, metrics=ds_metrics, access_state="accessible")) + + # 6. Resolve filter→chart mapping + for chart in charts: + chart.applied_filter_ids = [] + for nf in native_filters: + for t in nf.targets: + if t.dataset_id == chart.dataset_id: + if chart.chart_id not in chart.applied_filter_ids: + chart.applied_filter_ids.append(nf.filter_id) + + # 7. Capabilities + capabilities = DashboardCapabilities( + chart_data=True, dataset_query=bool(datasets), xlsx_export=False) + + # 8. Build model + fingerprint + model = DashboardQueryModel( + environment_id=environment_id, dashboard_id=dashboard_id, + title=title, slug=slug, + charts=charts, datasets=datasets, native_filters=native_filters, + capabilities=capabilities, warnings=warnings, + query_model_fingerprint="", + ) + + model_dict = model.model_dump(mode="json", exclude={"query_model_fingerprint"}) + model.query_model_fingerprint = _compute_fingerprint(model_dict) + return model +# #endregion BaselineEngine.QueryModel.Inspect diff --git a/backend/tests/services/dashboard_testing/test_filters.py b/backend/tests/services/dashboard_testing/test_filters.py new file mode 100644 index 000000000..e9f3f8b46 --- /dev/null +++ b/backend/tests/services/dashboard_testing/test_filters.py @@ -0,0 +1,252 @@ +#region Test.DashboardTesting.Filters [C:3] [TYPE Module] [SEMANTICS testing,baseline,filters,scope] +# @defgroup Tests for BaselineEngine.Filters.Normalize — filter scope, type, hash validation. +# @LAYER Test +# @RELATION VERIFIES -> [BaselineEngine.Filters.Normalize] + +from __future__ import annotations + +import pytest + +from src.schemas.dashboard_testing import ( + DashboardQueryModel, ChartQueryModel, DatasetQueryModel, ColumnInfo, + NativeFilterModel, FilterTarget, NormalizedFilter, NormalizedFilterContext, + FilterValue, MetricDescriptor, +) +from src.services.dashboard_testing.filters import normalize_filters + + +def _make_basic_query_model( + chart_ids: list[int] | None = None, + filter_targets: dict[str, list[int]] | None = None, +) -> DashboardQueryModel: + """Build a minimal query model for filter tests.""" + if chart_ids is None: + chart_ids = [128, 129] + + charts = [ + ChartQueryModel( + chart_id=cid, + slice_name=f"Chart {cid}", + viz_type="bar", + dataset_id=77, + dataset_name="public.finance_transactions", + metrics=[MetricDescriptor(metric_name="sum__revenue", label="SUM(revenue)", expression_type="SIMPLE")], + applied_filter_ids=[fid for fid, targets in (filter_targets or {}).items() if cid in targets], + excluded_filter_ids=[], + ) + for cid in chart_ids + ] + + if filter_targets is None: + filter_targets = {"NATIVE_FILTER-date": [128, 129], "NATIVE_FILTER-region": [128]} + + native_filters = [ + NativeFilterModel( + filter_id=fid, + filter_type="NATIVE_FILTER", + name=fid.replace("NATIVE_FILTER-", "").replace("-", " ").title(), + column="business_date" if "date" in fid else "business_region", + dataset_id=77, + type="DATE" if "date" in fid else "STRING", + targets=[FilterTarget(chart_id=cid, dataset_id=77) for cid in targets], + ) + for fid, targets in filter_targets.items() + ] + + return DashboardQueryModel( + environment_id="ss-preprod", + dashboard_id=42, + title="FI-0080 Finance Overview", + charts=charts, + datasets=[ + DatasetQueryModel( + dataset_id=77, + dataset_name="public.finance_transactions", + columns=[ + ColumnInfo(column_name="business_date", type="DATE", groupby=True, filterable=True), + ColumnInfo(column_name="business_region", type="STRING", groupby=True, filterable=True), + ], + ) + ], + native_filters=native_filters, + query_model_fingerprint="sha256:test", + ) + +# #region Test.DashboardTesting.Filters.BasicNormalization [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,scope] +def test_normalize_basic_date_filter(): + """T007: Basic date filter normalization produces correct typed output.""" + model = _make_basic_query_model() + + result = normalize_filters( + filter_inputs=[ + NormalizedFilter( + filter_id="NATIVE_FILTER-date", + dataset_id=77, + column="business_date", + operator="TEMPORAL_RANGE", + value=FilterValue(from_="2026-05-29", to="2026-05-29", inclusive=True), + target_chart_ids=[128, 129], + ) + ], + query_model=model, + ) + + assert isinstance(result, NormalizedFilterContext) + assert len(result.filters) == 1 + assert result.filters[0].filter_id == "NATIVE_FILTER-date" + assert result.filters[0].value.from_ == "2026-05-29" + assert result.filters_hash, "filters_hash must be computed" +# #endregion Test.DashboardTesting.Filters.BasicNormalization + +# #region Test.DashboardTesting.Filters.FilterHashDeterministic [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,hash] +def test_filter_hash_deterministic(): + """T007: Same filter inputs produce identical hash.""" + model = _make_basic_query_model() + filters = [ + NormalizedFilter( + filter_id="NATIVE_FILTER-date", + dataset_id=77, column="business_date", + operator="TEMPORAL_RANGE", + value=FilterValue(from_="2026-05-29", to="2026-05-29", inclusive=True), + target_chart_ids=[128, 129], + ) + ] + + r1 = normalize_filters(filters, model) + r2 = normalize_filters(filters, model) + assert r1.filters_hash == r2.filters_hash +# #endregion Test.DashboardTesting.Filters.FilterHashDeterministic + +# #region Test.DashboardTesting.Filters.ScopedFilterOnlyTargetCharts [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,scope] +def test_scoped_filter_only_target_charts(): + """T007: Filter scoped to chart 128 only includes relevant target_chart_ids.""" + model = _make_basic_query_model( + chart_ids=[128, 129], + filter_targets={"NATIVE_FILTER-region": [128]} # only chart 128 + ) + + result = normalize_filters( + filter_inputs=[ + NormalizedFilter( + filter_id="NATIVE_FILTER-region", + dataset_id=77, column="business_region", + operator="IN", + value=FilterValue(values=["West"]), + target_chart_ids=[128], + ) + ], + query_model=model, + ) + + assert result.filters[0].target_chart_ids == [128] +# #endregion Test.DashboardTesting.Filters.ScopedFilterOnlyTargetCharts + +# #region Test.DashboardTesting.Filters.FilterOutsideScopeRejected [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,edge-case] +def test_filter_outside_chart_scope_rejected(): + """T007: Filter targeting chart not in query model scope raises ValueError.""" + model = _make_basic_query_model( + chart_ids=[128], + filter_targets={"NATIVE_FILTER-date": [128]} + ) + + with pytest.raises(ValueError, match="not in query model"): + normalize_filters( + filter_inputs=[ + NormalizedFilter( + filter_id="NATIVE_FILTER-date", + dataset_id=77, column="business_date", + operator="TEMPORAL_RANGE", + value=FilterValue(from_="2026-06-01", to="2026-06-01"), + target_chart_ids=[999], # chart 999 not in model + ) + ], + query_model=model, + ) +# #endregion Test.DashboardTesting.Filters.FilterOutsideScopeRejected + +# #region Test.DashboardTesting.Filters.CanonicalOrder [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,order] +def test_canonical_filter_order_is_stable(): + """T007: Filters are sorted in canonical order (dataset_id, column, operator, filter_id).""" + model = _make_basic_query_model( + chart_ids=[128], + filter_targets={ + "Z-filter": [128], + "A-filter": [128], + } + ) + # Override native filters to have different dataset_ids for ordering test + # Also add dataset_id=99 to the model datasets + model.datasets.append( + DatasetQueryModel( + dataset_id=99, dataset_name="public.extra", + columns=[ColumnInfo(column_name="col_z", type="STRING", groupby=True, filterable=True)], + ) + ) + model.native_filters = [ + NativeFilterModel( + filter_id="Z-filter", filter_type="NATIVE_FILTER", name="Z", + column="col_z", dataset_id=99, type="STRING", + targets=[FilterTarget(chart_id=128, dataset_id=99)], + ), + NativeFilterModel( + filter_id="A-filter", filter_type="NATIVE_FILTER", name="A", + column="col_a", dataset_id=77, type="STRING", + targets=[FilterTarget(chart_id=128, dataset_id=77)], + ), + ] + + result = normalize_filters( + filter_inputs=[ + NormalizedFilter(filter_id="Z-filter", dataset_id=99, column="col_z", + operator="EQUALS", value=FilterValue(value="z"), + target_chart_ids=[128]), + NormalizedFilter(filter_id="A-filter", dataset_id=77, column="col_a", + operator="EQUALS", value=FilterValue(value="a"), + target_chart_ids=[128]), + ], + query_model=model, + ) + + # A-filter (dataset_id=77) should come before Z-filter (dataset_id=99) + assert result.filters[0].filter_id == "A-filter" + assert result.filters[1].filter_id == "Z-filter" +# #endregion Test.DashboardTesting.Filters.CanonicalOrder + +# #region Test.DashboardTesting.Filters.LocaleNotInHash [C:3] [TYPE Function] [SEMANTICS testing,baseline,filter,locale] +def test_locale_does_not_affect_hash(): + """T007: Locale formatting changes do NOT affect the filters_hash.""" + model = _make_basic_query_model() + + # Filter with locale-formatted decimal + r1 = normalize_filters( + filter_inputs=[ + NormalizedFilter( + filter_id="NATIVE_FILTER-date", + dataset_id=77, column="business_date", + operator="TEMPORAL_RANGE", + value=FilterValue(from_="2026-05-29", to="2026-05-29"), + target_chart_ids=[128, 129], + ) + ], + query_model=model, + ) + + # Same filter, but with different display format should produce same hash + # (value is already canonical, locale differences resolved upstream) + r2 = normalize_filters( + filter_inputs=[ + NormalizedFilter( + filter_id="NATIVE_FILTER-date", + dataset_id=77, column="business_date", + operator="TEMPORAL_RANGE", + value=FilterValue(from_="2026-05-29", to="2026-05-29"), + target_chart_ids=[128, 129], + ) + ], + query_model=model, + ) + + assert r1.filters_hash == r2.filters_hash +# #endregion Test.DashboardTesting.Filters.LocaleNotInHash + +#endregion Test.DashboardTesting.Filters diff --git a/backend/tests/services/dashboard_testing/test_query_model.py b/backend/tests/services/dashboard_testing/test_query_model.py new file mode 100644 index 000000000..8a7fd04e0 --- /dev/null +++ b/backend/tests/services/dashboard_testing/test_query_model.py @@ -0,0 +1,180 @@ +#region Test.DashboardTesting.QueryModel [C:3] [TYPE Module] [SEMANTICS testing,baseline,query-model,inspection] +# @defgroup Tests for BaselineEngine.QueryModel.Inspect — deterministic dashboard query model inspection. +# @LAYER Test +# @RELATION VERIFIES -> [BaselineEngine.QueryModel.Inspect] + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.schemas.dashboard_testing import DashboardQueryModel +from src.services.dashboard_testing.query_model import inspect_dashboard_query_model + +FIXTURES = Path(__file__).parent.parent.parent / "fixtures" / "dashboard_testing" + + +def _load_fixture(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + + +def _make_mock_client( + *, + dashboard_result: dict | None = None, + charts_result: list[dict] | None = None, + datasets_result: list[dict] | None = None, + chart_detail_result: dict | None = None, +) -> AsyncMock: + """Build a mock SupersetClient with get_dashboard, get_dashboard_charts, get_dashboard_datasets, get_chart.""" + client = AsyncMock() + client.get_dashboard = AsyncMock(return_value={ + "result": dashboard_result or { + "id": 42, "dashboard_title": "FI-0080 Finance Overview", + "slug": "fi-0080-finance-overview", + "json_metadata": json.dumps({"native_filter_configuration": []}), + "position_json": json.dumps({}), + } + }) + client.get_dashboard_charts = AsyncMock(return_value=charts_result or []) + client.get_dashboard_datasets = AsyncMock(return_value=datasets_result or []) + client.get_chart = AsyncMock(return_value={"result": chart_detail_result or {}}) + return client + +# @region Test.DashboardTesting.QueryModel.BasicInspection [C:3] [TYPE Function] [SEMANTICS testing,baseline,query-model] +@pytest.mark.asyncio +async def test_inspect_basic_dashboard_structure(): + """T006: Verify basic structure of inspected dashboard — title, charts, datasets, filters.""" + client = _make_mock_client( + dashboard_result={ + "id": 42, "dashboard_title": "FI-0080 Finance Overview", + "slug": "fi-0080-finance-overview", + "json_metadata": json.dumps({ + "native_filter_configuration": [ + {"id": "NATIVE_FILTER-date", "name": "Business Date", + "filterType": "filter_date", + "targets": [{"datasetId": 77, "column": {"name": "business_date"}}]}, + {"id": "NATIVE_FILTER-region", "name": "Region", + "filterType": "filter_select", + "targets": [{"datasetId": 77, "column": {"name": "business_region"}}]}, + ] + }), + "position_json": json.dumps({ + "CHART-128": {"id": "CHART-128", "meta": {"chartId": 128, "uuid": "aaa", + "sliceName": "Monthly Revenue", "width": 6, "height": 12}}, + }), + }, + charts_result=[ + {"id": 128, "uuid": "c9e2e4a8-1234-4abc-9def-0123456789ab", + "slice_name": "Monthly Revenue by Region", "viz_type": "bar", + "datasource_id": 77, "datasource_type": "table", + "datasource_name_text": "public.finance_transactions", + "params": json.dumps({"metrics": ["sum__revenue", "count"], + "groupby": ["business_region"]})}, + ], + datasets_result=[ + {"id": 77, "uuid": "d77a1234-abcd-4efg-hijk-lmnopqrstuv", + "table_name": "public.finance_transactions", + "columns": [ + {"column_name": "business_date", "type": "DATE", "groupby": True, "filterable": True}, + {"column_name": "business_region", "type": "STRING", "groupby": True, "filterable": True}, + ], + "metrics": [ + {"metric_name": "sum__revenue", "verbose_name": "SUM(revenue)", "expression": "SUM(revenue)"}, + ]}, + ], + ) + + result = await inspect_dashboard_query_model(client, "ss-preprod", 42) + + assert isinstance(result, DashboardQueryModel) + assert result.environment_id == "ss-preprod" + assert result.dashboard_id == 42 + assert result.title == "FI-0080 Finance Overview" + assert len(result.charts) >= 1 + assert len(result.datasets) >= 1 + assert len(result.native_filters) >= 2 + assert result.capabilities.chart_data is True + assert result.query_model_fingerprint +# @endregion Test.DashboardTesting.QueryModel.BasicInspection + +# @region Test.DashboardTesting.QueryModel.DeterministicOutput [C:3] [TYPE Function] [SEMANTICS testing,baseline,deterministic] +@pytest.mark.asyncio +async def test_deterministic_inspection_output(): + """T006: Two inspections of same dashboard produce identical JSON snapshots.""" + client = _make_mock_client( + dashboard_result={ + "id": 42, "dashboard_title": "FI-0080 Finance Overview", + "slug": "fi-0080-finance-overview", + "json_metadata": json.dumps({"native_filter_configuration": []}), + "position_json": json.dumps({ + "CHART-128": {"id": "CHART-128", "meta": {"chartId": 128}}, + }), + }, + charts_result=[ + {"id": 128, "uuid": "aaa", "slice_name": "Chart A", "viz_type": "bar", + "datasource_id": 77, "datasource_type": "table", + "datasource_name_text": "public.finance_transactions", + "params": json.dumps({"metrics": ["sum__revenue"], "groupby": []})}, + ], + datasets_result=[ + {"id": 77, "uuid": "d77", "table_name": "public.finance_transactions", + "columns": [{"column_name": "id", "type": "INTEGER", "groupby": False, "filterable": False}], + "metrics": []}, + ], + ) + + result1 = await inspect_dashboard_query_model(client, "dev", 42) + result2 = await inspect_dashboard_query_model(client, "dev", 42) + + json1 = result1.model_dump_json(exclude={"query_model_fingerprint"}) + json2 = result2.model_dump_json(exclude={"query_model_fingerprint"}) + assert json1 == json2, "Deterministic inspection must produce identical JSON" +# @endregion Test.DashboardTesting.QueryModel.DeterministicOutput + +# @region Test.DashboardTesting.QueryModel.InaccessibleChart [C:3] [TYPE Function] [SEMANTICS testing,baseline,edge-case] +@pytest.mark.asyncio +async def test_inaccessible_chart_produces_warning(): + """T006: Inaccessible chart returns warning + execution_capable=False.""" + from src.core.utils.network import SupersetAPIError + + client = _make_mock_client( + dashboard_result={ + "id": 42, "dashboard_title": "Test", "slug": "test", + "json_metadata": json.dumps({}), + "position_json": json.dumps({ + "CHART-999": {"id": "CHART-999", "meta": {"chartId": 999}}, + }), + }, + charts_result=[], # no charts via dashboard endpoint + ) + # get_chart will raise for chart 999 + client.get_chart = AsyncMock(side_effect=SupersetAPIError("Forbidden", status_code=403)) + + result = await inspect_dashboard_query_model(client, "dev", 42) + + assert len(result.warnings) > 0 + assert any(w.code == "INACCESSIBLE_CHART" for w in result.warnings) +# @endregion Test.DashboardTesting.QueryModel.InaccessibleChart + +# @region Test.DashboardTesting.QueryModel.MissingMetadataNotInvented [C:3] [TYPE Function] [SEMANTICS testing,baseline,invariant] +@pytest.mark.asyncio +async def test_missing_metadata_not_invented(): + """T006: When Superset returns empty chart list, charts are empty — never fabricated.""" + client = _make_mock_client( + dashboard_result={ + "id": 42, "dashboard_title": "Empty Dashboard", "slug": "empty", + "json_metadata": json.dumps({}), + "position_json": json.dumps({}), + }, + charts_result=[], + ) + + result = await inspect_dashboard_query_model(client, "dev", 42) + assert len(result.charts) == 0 + assert result.title == "Empty Dashboard" +# @endregion Test.DashboardTesting.QueryModel.MissingMetadataNotInvented + +#endregion Test.DashboardTesting.QueryModel