agnes-the-ai-analyst/tests/test_metrics.py
Petr 5fc9526627 Phase 2: Replace demo YAML metrics with OpenMetadata catalog data
- Add get_metric_by_fqn() to OpenMetadataClient
- Add get_metrics() to CatalogEnricher with TTL caching
- Implement _parse_om_metric() to extract category/grain from OpenMetadata tags
- Implement _load_metrics_from_catalog() to fetch and categorize metrics
- Implement _build_om_metric_detail() to convert OpenMetadata format to MetricParser JSON
- Add /api/catalog/metrics/<fqn> endpoint for metric detail modal
- Update _load_metrics_data() to prefer catalog over YAML fallback
- Update metric_modal.js to route catalog:{fqn} to catalog API endpoint
- Delete 10 demo YAML files from docs/metrics/
- Replace metric tests with new unit tests for catalog parsing functions (19 tests)

Catalog metrics provide single source of truth vs maintaining demo YAML files.
UI remains unchanged - only data source changes from YAML to OpenMetadata catalog.
2026-03-12 15:10:42 +01:00

352 lines
12 KiB
Python

"""Tests for OpenMetadata catalog metrics and parsing functions."""
import pytest
from unittest.mock import Mock, MagicMock, patch
from webapp.app import _parse_om_metric, _load_metrics_from_catalog, _build_om_metric_detail, METRIC_CATEGORY_META
class TestParseOmMetric:
"""Unit tests for _parse_om_metric() function."""
def test_parse_metric_basic_fields(self):
"""Extract basic fields from raw metric."""
raw = {
"fullyQualifiedName": "catalog.metrics.total_revenue",
"name": "total_revenue",
"displayName": "Total Revenue",
"description": "Total revenue from all orders",
"tags": [],
}
result = _parse_om_metric(raw)
assert result["name"] == "total_revenue"
assert result["display_name"] == "Total Revenue"
assert result["description"] == "Total revenue from all orders"
assert result["path"] == "catalog:catalog.metrics.total_revenue"
def test_parse_metric_with_category_tag(self):
"""Extract category from MetricCategory.* tag."""
raw = {
"fullyQualifiedName": "catalog.metrics.revenue_metric",
"name": "revenue_metric",
"displayName": "Revenue",
"description": "Test",
"tags": [
{"tagFQN": "MetricCategory.finance"},
{"tagFQN": "Grain.monthly"},
],
}
result = _parse_om_metric(raw)
assert result["category"] == "finance"
assert result["grain"] == "monthly"
def test_parse_metric_with_category_legacy_tag(self):
"""Extract category from Category.* tag (legacy)."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test",
"displayName": "Test",
"description": "Test",
"tags": [
{"tagFQN": "Category.marketing"},
],
}
result = _parse_om_metric(raw)
assert result["category"] == "marketing"
def test_parse_metric_fallback_to_general(self):
"""Default to 'general' category if no category tag."""
raw = {
"fullyQualifiedName": "catalog.metrics.unknown",
"name": "unknown",
"displayName": "Unknown",
"description": "Test",
"tags": [],
}
result = _parse_om_metric(raw)
assert result["category"] == "general"
def test_parse_metric_display_name_fallback(self):
"""Use name as display_name if displayName not provided."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test_metric",
"description": "Test",
"tags": [],
}
result = _parse_om_metric(raw)
assert result["display_name"] == "test_metric"
def test_parse_metric_path_has_catalog_prefix(self):
"""Path field includes catalog: prefix for JS routing."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test",
"displayName": "Test",
"description": "Test",
"tags": [],
}
result = _parse_om_metric(raw)
assert result["path"].startswith("catalog:")
class TestLoadMetricsFromCatalog:
"""Tests for _load_metrics_from_catalog() with mocked enricher."""
@patch('webapp.app._catalog_enricher')
def test_returns_empty_list_if_enricher_disabled(self, mock_enricher):
"""Return empty list if enricher not enabled."""
mock_enricher.enabled = False
result = _load_metrics_from_catalog()
assert result == []
@patch('webapp.app._catalog_enricher')
def test_returns_empty_list_if_enricher_none(self, mock_enricher):
"""Return empty list if enricher is None."""
with patch('webapp.app._catalog_enricher', None):
result = _load_metrics_from_catalog()
assert result == []
@patch('webapp.app._catalog_enricher')
def test_groups_metrics_by_category(self, mock_enricher):
"""Group metrics by category key."""
mock_enricher.enabled = True
mock_enricher.get_metrics.return_value = [
{
"fullyQualifiedName": "catalog.metrics.finance_metric",
"name": "finance_metric",
"displayName": "Finance Metric",
"description": "Test",
"tags": [{"tagFQN": "MetricCategory.finance"}],
},
{
"fullyQualifiedName": "catalog.metrics.marketing_metric",
"name": "marketing_metric",
"displayName": "Marketing Metric",
"description": "Test",
"tags": [{"tagFQN": "MetricCategory.marketing"}],
},
]
with patch('webapp.app._catalog_enricher', mock_enricher):
result = _load_metrics_from_catalog()
# Should have at least one of the known categories from METRIC_CATEGORY_META
assert len(result) >= 1
keys = [c["key"] for c in result]
assert "finance" in keys or "marketing" in keys
assert all(len(c["metrics"]) > 0 for c in result)
@patch('webapp.app._catalog_enricher')
def test_uses_metric_category_meta_order(self, mock_enricher):
"""Result categories ordered by METRIC_CATEGORY_META."""
mock_enricher.enabled = True
mock_enricher.get_metrics.return_value = [
{
"fullyQualifiedName": "catalog.metrics.m1",
"name": "m1",
"displayName": "M1",
"description": "Test",
"tags": [{"tagFQN": "MetricCategory.revenue"}],
},
{
"fullyQualifiedName": "catalog.metrics.m2",
"name": "m2",
"displayName": "M2",
"description": "Test",
"tags": [{"tagFQN": "MetricCategory.customers"}],
},
]
with patch('webapp.app._catalog_enricher', mock_enricher):
result = _load_metrics_from_catalog()
# revenue should come before customers per METRIC_CATEGORY_META order
keys = [c["key"] for c in result]
if "revenue" in keys and "customers" in keys:
revenue_idx = keys.index("revenue")
customers_idx = keys.index("customers")
assert revenue_idx < customers_idx
@patch('webapp.app._catalog_enricher')
def test_uses_category_label_from_meta(self, mock_enricher):
"""Category label comes from METRIC_CATEGORY_META."""
mock_enricher.enabled = True
mock_enricher.get_metrics.return_value = [
{
"fullyQualifiedName": "catalog.metrics.m1",
"name": "m1",
"displayName": "M1",
"description": "Test",
"tags": [{"tagFQN": "MetricCategory.revenue"}],
},
]
with patch('webapp.app._catalog_enricher', mock_enricher):
result = _load_metrics_from_catalog()
# Verify that a known category gets its label from METRIC_CATEGORY_META
assert len(result) >= 1
revenue_cat = [c for c in result if c["key"] == "revenue"]
if revenue_cat:
assert revenue_cat[0]["label"] == METRIC_CATEGORY_META["revenue"]["label"]
assert revenue_cat[0]["css"] == METRIC_CATEGORY_META["revenue"]["css"]
@patch('webapp.app._catalog_enricher')
def test_graceful_failure_on_exception(self, mock_enricher):
"""Return empty list on exception (graceful degradation)."""
mock_enricher.enabled = True
mock_enricher.get_metrics.side_effect = Exception("API error")
with patch('webapp.app._catalog_enricher', mock_enricher):
result = _load_metrics_from_catalog()
assert result == []
@patch('webapp.app._catalog_enricher')
def test_empty_metrics_list(self, mock_enricher):
"""Return empty list when catalog has no metrics."""
mock_enricher.enabled = True
mock_enricher.get_metrics.return_value = []
with patch('webapp.app._catalog_enricher', mock_enricher):
result = _load_metrics_from_catalog()
assert result == []
class TestBuildOmMetricDetail:
"""Tests for _build_om_metric_detail() function."""
def test_build_basic_structure(self):
"""Build MetricParser-compatible structure from raw metric."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test_metric",
"displayName": "Test Metric",
"description": "A test metric",
"expression": "COUNT(*)",
"owners": [{"name": "data_team"}],
"tags": [],
}
result = _build_om_metric_detail(raw)
assert result["name"] == "test_metric"
assert result["display_name"] == "Test Metric"
assert result["category"] == "general"
assert result["metadata"]["type"] == ""
assert result["metadata"]["unit"] == ""
assert result["metadata"]["grain"] == ""
assert result["overview"]["description"] == "A test metric"
def test_extract_metadata_from_tags(self):
"""Extract type, unit, grain from tags."""
raw = {
"fullyQualifiedName": "catalog.metrics.revenue",
"name": "revenue",
"displayName": "Revenue",
"description": "Test",
"expression": "SUM(amount)",
"owners": [],
"tags": [
{"tagFQN": "MetricType.sum"},
{"tagFQN": "Unit.usd"},
{"tagFQN": "Grain.monthly"},
{"tagFQN": "MetricCategory.finance"},
],
}
result = _build_om_metric_detail(raw)
assert result["metadata"]["type"] == "sum"
assert result["metadata"]["unit"] == "usd"
assert result["metadata"]["grain"] == "monthly"
assert result["category"] == "finance"
def test_extract_dimensions_from_tags(self):
"""Extract dimension names from Dimension.* tags."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test",
"displayName": "Test",
"description": "Test",
"expression": "SELECT",
"owners": [],
"tags": [
{"tagFQN": "Dimension.region"},
{"tagFQN": "Dimension.channel"},
],
}
result = _build_om_metric_detail(raw)
assert "region" in result["dimensions"]
assert "channel" in result["dimensions"]
def test_expression_in_sql_examples(self):
"""Expression field goes into sql_examples for modal display."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test",
"displayName": "Test",
"description": "Test",
"expression": "SELECT COUNT(*) FROM users",
"owners": [],
"tags": [],
}
result = _build_om_metric_detail(raw)
assert "expression" in result["sql_examples"]
assert result["sql_examples"]["expression"]["query"] == "SELECT COUNT(*) FROM users"
assert result["sql_examples"]["expression"]["title"] == "Metric Expression"
def test_extract_owner_names(self):
"""Extract owner names from owners list."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test",
"displayName": "Test",
"description": "Test",
"expression": "SELECT",
"owners": [
{"name": "alice", "email": "alice@example.com"},
{"name": "bob"},
],
"tags": [],
}
result = _build_om_metric_detail(raw)
# Owner names go to notes.all
assert len(result["notes"]["all"]) == 0 # We don't populate this from owners yet
def test_empty_expression_no_sql_example(self):
"""Don't add empty expression to sql_examples."""
raw = {
"fullyQualifiedName": "catalog.metrics.test",
"name": "test",
"displayName": "Test",
"description": "Test",
"expression": "",
"owners": [],
"tags": [],
}
result = _build_om_metric_detail(raw)
assert result["sql_examples"] == {}