All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
- API-Referenz Seite (/sdk/api-docs) mit ~690 Endpoints, Suche, Filter, Modul-Index - Shared db_utils.py (row_to_dict) + tenant_utils Integration in 6 Route-Dateien - CRUD Factory (crud_factory.py) fuer zukuenftige Module - Version-Route Auto-Registration in versioning_utils.py - 1338 Tests bestanden, -232 Zeilen Duplikat-Code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
906 lines
32 KiB
Python
906 lines
32 KiB
Python
"""Tests for AI Quality Metrics and Tests routes (quality_routes.py).
|
|
|
|
Covers:
|
|
- Schema validation (MetricCreate, MetricUpdate, TestCreate, TestUpdate)
|
|
- Helper functions (_row_to_dict, _get_tenant_id)
|
|
- HTTP endpoints via FastAPI TestClient with mocked DB session
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
from datetime import datetime
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from compliance.api.quality_routes import (
|
|
router,
|
|
MetricCreate,
|
|
MetricUpdate,
|
|
TestCreate,
|
|
TestUpdate,
|
|
)
|
|
from compliance.api.db_utils import row_to_dict as _row_to_dict
|
|
|
|
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
|
from classroom_engine.database import get_db
|
|
|
|
# =============================================================================
|
|
# TestClient Setup
|
|
# =============================================================================
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
|
OTHER_TENANT = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
|
|
METRIC_ID = "bbbbbbbb-0001-0001-0001-000000000001"
|
|
TEST_ID = "cccccccc-0001-0001-0001-000000000001"
|
|
|
|
|
|
def make_metric_row(overrides=None):
|
|
data = {
|
|
"id": METRIC_ID,
|
|
"tenant_id": DEFAULT_TENANT,
|
|
"name": "Test Metric",
|
|
"category": "accuracy",
|
|
"score": 85.0,
|
|
"threshold": 80.0,
|
|
"trend": "stable",
|
|
"ai_system": None,
|
|
"last_measured": datetime(2024, 1, 1),
|
|
"created_at": datetime(2024, 1, 1),
|
|
"updated_at": datetime(2024, 1, 1),
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
row = MagicMock()
|
|
row._mapping = data
|
|
return row
|
|
|
|
|
|
def make_test_row(overrides=None):
|
|
data = {
|
|
"id": TEST_ID,
|
|
"tenant_id": DEFAULT_TENANT,
|
|
"name": "Test Run",
|
|
"status": "passed",
|
|
"duration": "1.2s",
|
|
"ai_system": None,
|
|
"details": None,
|
|
"last_run": datetime(2024, 1, 1),
|
|
"created_at": datetime(2024, 1, 1),
|
|
"updated_at": datetime(2024, 1, 1),
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
row = MagicMock()
|
|
row._mapping = data
|
|
return row
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
db = MagicMock()
|
|
|
|
def override_get_db():
|
|
yield db
|
|
|
|
app.dependency_overrides[get_db] = override_get_db
|
|
yield db
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# =============================================================================
|
|
# Schema Tests — MetricCreate
|
|
# =============================================================================
|
|
|
|
class TestMetricCreate:
|
|
def test_minimal_valid(self):
|
|
req = MetricCreate(name="Accuracy Score")
|
|
assert req.name == "Accuracy Score"
|
|
assert req.category == "accuracy"
|
|
assert req.score == 0.0
|
|
assert req.threshold == 80.0
|
|
assert req.trend == "stable"
|
|
assert req.ai_system is None
|
|
assert req.last_measured is None
|
|
|
|
def test_full_values(self):
|
|
ts = datetime(2026, 3, 1)
|
|
req = MetricCreate(
|
|
name="Fairness Score",
|
|
category="fairness",
|
|
score=92.5,
|
|
threshold=85.0,
|
|
trend="improving",
|
|
ai_system="RecSys-v2",
|
|
last_measured=ts,
|
|
)
|
|
assert req.name == "Fairness Score"
|
|
assert req.category == "fairness"
|
|
assert req.score == 92.5
|
|
assert req.trend == "improving"
|
|
assert req.ai_system == "RecSys-v2"
|
|
assert req.last_measured == ts
|
|
|
|
def test_serialization_excludes_none(self):
|
|
req = MetricCreate(name="Drift Score", score=75.0)
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data["name"] == "Drift Score"
|
|
assert data["score"] == 75.0
|
|
assert "ai_system" not in data
|
|
assert "last_measured" not in data
|
|
|
|
def test_default_trend_stable(self):
|
|
req = MetricCreate(name="Test")
|
|
assert req.trend == "stable"
|
|
|
|
def test_default_score_zero(self):
|
|
req = MetricCreate(name="Test")
|
|
assert req.score == 0.0
|
|
|
|
|
|
# =============================================================================
|
|
# Schema Tests — MetricUpdate
|
|
# =============================================================================
|
|
|
|
class TestMetricUpdate:
|
|
def test_empty_update(self):
|
|
req = MetricUpdate()
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert data == {}
|
|
|
|
def test_partial_score_update(self):
|
|
req = MetricUpdate(score=95.0)
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert data == {"score": 95.0}
|
|
|
|
def test_trend_and_threshold_update(self):
|
|
req = MetricUpdate(trend="declining", threshold=90.0)
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert data["trend"] == "declining"
|
|
assert data["threshold"] == 90.0
|
|
assert "name" not in data
|
|
|
|
def test_full_update(self):
|
|
req = MetricUpdate(
|
|
name="New Name",
|
|
category="robustness",
|
|
score=88.0,
|
|
threshold=85.0,
|
|
trend="improving",
|
|
ai_system="ModelB",
|
|
)
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert len(data) == 6
|
|
assert data["category"] == "robustness"
|
|
|
|
|
|
# =============================================================================
|
|
# Schema Tests — TestCreate
|
|
# =============================================================================
|
|
|
|
class TestTestCreate:
|
|
def test_minimal_valid(self):
|
|
req = TestCreate(name="Bias Detection Test")
|
|
assert req.name == "Bias Detection Test"
|
|
assert req.status == "pending"
|
|
assert req.duration is None
|
|
assert req.ai_system is None
|
|
assert req.details is None
|
|
assert req.last_run is None
|
|
|
|
def test_full_values(self):
|
|
ts = datetime(2026, 3, 1, 12, 0, 0)
|
|
req = TestCreate(
|
|
name="Accuracy Test Suite",
|
|
status="passed",
|
|
duration="3.45s",
|
|
ai_system="ClassifierV3",
|
|
details="All 500 samples passed",
|
|
last_run=ts,
|
|
)
|
|
assert req.status == "passed"
|
|
assert req.duration == "3.45s"
|
|
assert req.ai_system == "ClassifierV3"
|
|
assert req.details == "All 500 samples passed"
|
|
assert req.last_run == ts
|
|
|
|
def test_failed_status(self):
|
|
req = TestCreate(name="Fairness Check", status="failed")
|
|
assert req.status == "failed"
|
|
|
|
def test_serialization_excludes_none(self):
|
|
req = TestCreate(name="Quick Test", status="passed")
|
|
data = req.model_dump(exclude_none=True)
|
|
assert "duration" not in data
|
|
assert "ai_system" not in data
|
|
|
|
|
|
# =============================================================================
|
|
# Schema Tests — TestUpdate
|
|
# =============================================================================
|
|
|
|
class TestTestUpdate:
|
|
def test_empty_update(self):
|
|
req = TestUpdate()
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert data == {}
|
|
|
|
def test_status_update(self):
|
|
req = TestUpdate(status="failed")
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert data == {"status": "failed"}
|
|
|
|
def test_duration_and_details_update(self):
|
|
req = TestUpdate(duration="10.5s", details="Timeout on 3 samples")
|
|
data = req.model_dump(exclude_unset=True)
|
|
assert data["duration"] == "10.5s"
|
|
assert data["details"] == "Timeout on 3 samples"
|
|
assert "name" not in data
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Tests — _row_to_dict
|
|
# =============================================================================
|
|
|
|
class TestRowToDict:
|
|
def test_basic_metric_conversion(self):
|
|
row = make_metric_row()
|
|
result = _row_to_dict(row)
|
|
assert result["id"] == METRIC_ID
|
|
assert result["name"] == "Test Metric"
|
|
assert result["score"] == 85.0
|
|
assert result["threshold"] == 80.0
|
|
|
|
def test_datetime_serialized(self):
|
|
ts = datetime(2024, 6, 15, 9, 0, 0)
|
|
row = make_metric_row({"created_at": ts, "last_measured": ts})
|
|
result = _row_to_dict(row)
|
|
assert result["created_at"] == ts.isoformat()
|
|
assert result["last_measured"] == ts.isoformat()
|
|
|
|
def test_none_values_preserved(self):
|
|
row = make_metric_row()
|
|
result = _row_to_dict(row)
|
|
assert result["ai_system"] is None
|
|
|
|
def test_uuid_converted_to_string(self):
|
|
import uuid
|
|
uid = uuid.UUID(DEFAULT_TENANT)
|
|
row = MagicMock()
|
|
row._mapping = {"id": uid, "tenant_id": uid}
|
|
result = _row_to_dict(row)
|
|
assert result["id"] == str(uid)
|
|
|
|
def test_numeric_fields_unchanged(self):
|
|
row = MagicMock()
|
|
row._mapping = {"score": 92.5, "threshold": 80.0, "count": 10}
|
|
result = _row_to_dict(row)
|
|
assert result["score"] == 92.5
|
|
assert result["threshold"] == 80.0
|
|
assert result["count"] == 10
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — GET /quality/stats
|
|
# =============================================================================
|
|
|
|
class TestGetQualityStats:
|
|
def test_stats_all_zeros(self, mock_db):
|
|
metrics_row = MagicMock()
|
|
metrics_row.total_metrics = 0
|
|
metrics_row.avg_score = 0
|
|
metrics_row.metrics_above_threshold = 0
|
|
|
|
tests_row = MagicMock()
|
|
tests_row.passed = 0
|
|
tests_row.failed = 0
|
|
tests_row.warning = 0
|
|
tests_row.total = 0
|
|
|
|
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
|
|
|
response = client.get("/quality/stats")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_metrics"] == 0
|
|
assert data["avg_score"] == 0.0
|
|
assert data["metrics_above_threshold"] == 0
|
|
assert data["passed"] == 0
|
|
assert data["failed"] == 0
|
|
assert data["warning"] == 0
|
|
assert data["total_tests"] == 0
|
|
|
|
def test_stats_with_data(self, mock_db):
|
|
metrics_row = MagicMock()
|
|
metrics_row.total_metrics = 5
|
|
metrics_row.avg_score = 87.4
|
|
metrics_row.metrics_above_threshold = 4
|
|
|
|
tests_row = MagicMock()
|
|
tests_row.passed = 10
|
|
tests_row.failed = 2
|
|
tests_row.warning = 1
|
|
tests_row.total = 13
|
|
|
|
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
|
|
|
response = client.get("/quality/stats")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_metrics"] == 5
|
|
assert data["avg_score"] == 87.4
|
|
assert data["metrics_above_threshold"] == 4
|
|
assert data["passed"] == 10
|
|
assert data["failed"] == 2
|
|
assert data["warning"] == 1
|
|
assert data["total_tests"] == 13
|
|
|
|
def test_stats_none_values_become_zero(self, mock_db):
|
|
metrics_row = MagicMock()
|
|
metrics_row.total_metrics = None
|
|
metrics_row.avg_score = None
|
|
metrics_row.metrics_above_threshold = None
|
|
|
|
tests_row = MagicMock()
|
|
tests_row.passed = None
|
|
tests_row.failed = None
|
|
tests_row.warning = None
|
|
tests_row.total = None
|
|
|
|
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
|
|
|
response = client.get("/quality/stats")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_metrics"] == 0
|
|
assert data["avg_score"] == 0.0
|
|
assert data["total_tests"] == 0
|
|
|
|
def test_stats_with_tenant_header(self, mock_db):
|
|
metrics_row = MagicMock()
|
|
metrics_row.total_metrics = 2
|
|
metrics_row.avg_score = 90.0
|
|
metrics_row.metrics_above_threshold = 2
|
|
|
|
tests_row = MagicMock()
|
|
tests_row.passed = 5
|
|
tests_row.failed = 0
|
|
tests_row.warning = 0
|
|
tests_row.total = 5
|
|
|
|
mock_db.execute.return_value.fetchone.side_effect = [metrics_row, tests_row]
|
|
|
|
response = client.get(
|
|
"/quality/stats",
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["total_metrics"] == 2
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — GET /quality/metrics
|
|
# =============================================================================
|
|
|
|
class TestListMetrics:
|
|
def test_empty_list(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 0
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = []
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/metrics")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["metrics"] == []
|
|
assert data["total"] == 0
|
|
|
|
def test_list_with_data(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 2
|
|
m1 = make_metric_row()
|
|
m2 = make_metric_row({"id": "bbbbbbbb-0002-0002-0002-000000000002", "name": "Second Metric"})
|
|
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [m1, m2]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/metrics")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["metrics"]) == 2
|
|
assert data["total"] == 2
|
|
|
|
def test_filter_by_category(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 1
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [make_metric_row({"category": "fairness"})]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/metrics?category=fairness")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["metrics"][0]["category"] == "fairness"
|
|
|
|
def test_filter_by_ai_system(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 1
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [make_metric_row({"ai_system": "ModelAlpha"})]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/metrics?ai_system=ModelAlpha")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["metrics"][0]["ai_system"] == "ModelAlpha"
|
|
|
|
def test_pagination(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 20
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [make_metric_row()]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/metrics?limit=1&offset=10")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 20
|
|
assert len(data["metrics"]) == 1
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — POST /quality/metrics
|
|
# =============================================================================
|
|
|
|
class TestCreateMetric:
|
|
def test_create_success(self, mock_db):
|
|
created_row = make_metric_row({"name": "New Metric"})
|
|
mock_db.execute.return_value.fetchone.return_value = created_row
|
|
|
|
response = client.post(
|
|
"/quality/metrics",
|
|
json={"name": "New Metric"},
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["name"] == "New Metric"
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_create_full_metric(self, mock_db):
|
|
created_row = make_metric_row({
|
|
"name": "Robustness Score",
|
|
"category": "robustness",
|
|
"score": 78.5,
|
|
"threshold": 75.0,
|
|
"trend": "improving",
|
|
})
|
|
mock_db.execute.return_value.fetchone.return_value = created_row
|
|
|
|
response = client.post("/quality/metrics", json={
|
|
"name": "Robustness Score",
|
|
"category": "robustness",
|
|
"score": 78.5,
|
|
"threshold": 75.0,
|
|
"trend": "improving",
|
|
})
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["category"] == "robustness"
|
|
assert data["score"] == 78.5
|
|
|
|
def test_create_missing_name_fails(self, mock_db):
|
|
response = client.post("/quality/metrics", json={"score": 90.0})
|
|
assert response.status_code == 422
|
|
|
|
def test_create_with_tenant_header(self, mock_db):
|
|
created_row = make_metric_row({"tenant_id": OTHER_TENANT})
|
|
mock_db.execute.return_value.fetchone.return_value = created_row
|
|
|
|
response = client.post(
|
|
"/quality/metrics",
|
|
json={"name": "Tenant B metric"},
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert response.status_code == 201
|
|
assert mock_db.commit.called
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — PUT /quality/metrics/{id}
|
|
# =============================================================================
|
|
|
|
class TestUpdateMetric:
|
|
def test_update_success(self, mock_db):
|
|
updated_row = make_metric_row({"score": 95.0, "trend": "improving"})
|
|
mock_db.execute.return_value.fetchone.return_value = updated_row
|
|
|
|
response = client.put(
|
|
f"/quality/metrics/{METRIC_ID}",
|
|
json={"score": 95.0, "trend": "improving"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["score"] == 95.0
|
|
assert data["trend"] == "improving"
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_update_not_found_returns_404(self, mock_db):
|
|
mock_db.execute.return_value.fetchone.return_value = None
|
|
|
|
response = client.put(
|
|
"/quality/metrics/nonexistent-id",
|
|
json={"score": 50.0},
|
|
)
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
def test_update_no_fields_returns_400(self, mock_db):
|
|
response = client.put(f"/quality/metrics/{METRIC_ID}", json={})
|
|
assert response.status_code == 400
|
|
|
|
def test_partial_update_category(self, mock_db):
|
|
updated_row = make_metric_row({"category": "explainability"})
|
|
mock_db.execute.return_value.fetchone.return_value = updated_row
|
|
|
|
response = client.put(
|
|
f"/quality/metrics/{METRIC_ID}",
|
|
json={"category": "explainability"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["category"] == "explainability"
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — DELETE /quality/metrics/{id}
|
|
# =============================================================================
|
|
|
|
class TestDeleteMetric:
|
|
def test_delete_success_204(self, mock_db):
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 1
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete(f"/quality/metrics/{METRIC_ID}")
|
|
assert response.status_code == 204
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_not_found_returns_404(self, mock_db):
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 0
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete("/quality/metrics/nonexistent-id")
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
def test_delete_with_tenant_header(self, mock_db):
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 1
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete(
|
|
f"/quality/metrics/{METRIC_ID}",
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — GET /quality/tests
|
|
# =============================================================================
|
|
|
|
class TestListQualityTests:
|
|
def test_empty_list(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 0
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = []
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/tests")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["tests"] == []
|
|
assert data["total"] == 0
|
|
|
|
def test_list_with_data(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 2
|
|
t1 = make_test_row()
|
|
t2 = make_test_row({"id": "cccccccc-0002-0002-0002-000000000002", "name": "Second Test"})
|
|
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [t1, t2]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/tests")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["tests"]) == 2
|
|
assert data["total"] == 2
|
|
|
|
def test_filter_by_status(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 1
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [make_test_row({"status": "failed"})]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/tests?status=failed")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["tests"][0]["status"] == "failed"
|
|
|
|
def test_filter_by_ai_system(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 1
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [make_test_row({"ai_system": "ModelBeta"})]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/tests?ai_system=ModelBeta")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["tests"][0]["ai_system"] == "ModelBeta"
|
|
|
|
def test_pagination(self, mock_db):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 50
|
|
execute_result = MagicMock()
|
|
execute_result.fetchone.return_value = count_row
|
|
execute_result.fetchall.return_value = [make_test_row()]
|
|
mock_db.execute.return_value = execute_result
|
|
|
|
response = client.get("/quality/tests?limit=1&offset=5")
|
|
assert response.status_code == 200
|
|
assert response.json()["total"] == 50
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — POST /quality/tests
|
|
# =============================================================================
|
|
|
|
class TestCreateQualityTest:
|
|
def test_create_success(self, mock_db):
|
|
created_row = make_test_row({"name": "New Test Run"})
|
|
mock_db.execute.return_value.fetchone.return_value = created_row
|
|
|
|
response = client.post(
|
|
"/quality/tests",
|
|
json={"name": "New Test Run"},
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["name"] == "New Test Run"
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_create_full_test(self, mock_db):
|
|
created_row = make_test_row({
|
|
"name": "Full Test Suite",
|
|
"status": "passed",
|
|
"duration": "5.0s",
|
|
"ai_system": "Classifier",
|
|
"details": "All assertions passed",
|
|
})
|
|
mock_db.execute.return_value.fetchone.return_value = created_row
|
|
|
|
response = client.post("/quality/tests", json={
|
|
"name": "Full Test Suite",
|
|
"status": "passed",
|
|
"duration": "5.0s",
|
|
"ai_system": "Classifier",
|
|
"details": "All assertions passed",
|
|
})
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["status"] == "passed"
|
|
assert data["duration"] == "5.0s"
|
|
|
|
def test_create_missing_name_fails(self, mock_db):
|
|
response = client.post("/quality/tests", json={"status": "passed"})
|
|
assert response.status_code == 422
|
|
|
|
def test_create_failed_status(self, mock_db):
|
|
created_row = make_test_row({"status": "failed"})
|
|
mock_db.execute.return_value.fetchone.return_value = created_row
|
|
|
|
response = client.post(
|
|
"/quality/tests",
|
|
json={"name": "Failing test", "status": "failed"},
|
|
)
|
|
assert response.status_code == 201
|
|
assert response.json()["status"] == "failed"
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — PUT /quality/tests/{id}
|
|
# =============================================================================
|
|
|
|
class TestUpdateQualityTest:
|
|
def test_update_success(self, mock_db):
|
|
updated_row = make_test_row({"status": "failed", "details": "Assertion error on line 42"})
|
|
mock_db.execute.return_value.fetchone.return_value = updated_row
|
|
|
|
response = client.put(
|
|
f"/quality/tests/{TEST_ID}",
|
|
json={"status": "failed", "details": "Assertion error on line 42"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "failed"
|
|
assert data["details"] == "Assertion error on line 42"
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_update_not_found_returns_404(self, mock_db):
|
|
mock_db.execute.return_value.fetchone.return_value = None
|
|
|
|
response = client.put(
|
|
"/quality/tests/nonexistent-id",
|
|
json={"status": "passed"},
|
|
)
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
def test_update_no_fields_returns_400(self, mock_db):
|
|
response = client.put(f"/quality/tests/{TEST_ID}", json={})
|
|
assert response.status_code == 400
|
|
|
|
def test_update_duration_only(self, mock_db):
|
|
updated_row = make_test_row({"duration": "2.8s"})
|
|
mock_db.execute.return_value.fetchone.return_value = updated_row
|
|
|
|
response = client.put(
|
|
f"/quality/tests/{TEST_ID}",
|
|
json={"duration": "2.8s"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["duration"] == "2.8s"
|
|
|
|
def test_update_with_tenant_header(self, mock_db):
|
|
updated_row = make_test_row({"tenant_id": OTHER_TENANT, "status": "warning"})
|
|
mock_db.execute.return_value.fetchone.return_value = updated_row
|
|
|
|
response = client.put(
|
|
f"/quality/tests/{TEST_ID}",
|
|
json={"status": "warning"},
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "warning"
|
|
|
|
|
|
# =============================================================================
|
|
# HTTP Tests — DELETE /quality/tests/{id}
|
|
# =============================================================================
|
|
|
|
class TestDeleteQualityTest:
|
|
def test_delete_success_204(self, mock_db):
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 1
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete(f"/quality/tests/{TEST_ID}")
|
|
assert response.status_code == 204
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_not_found_returns_404(self, mock_db):
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 0
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete("/quality/tests/nonexistent-id")
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
def test_delete_with_tenant_header(self, mock_db):
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 1
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete(
|
|
f"/quality/tests/{TEST_ID}",
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert response.status_code == 204
|
|
assert mock_db.commit.called
|
|
|
|
|
|
# =============================================================================
|
|
# Tenant Isolation Tests
|
|
# =============================================================================
|
|
|
|
class TestTenantIsolation:
|
|
def test_metrics_tenant_isolation(self, mock_db):
|
|
"""Tenant A sees 3 metrics, Tenant B sees 0."""
|
|
def side_effect(query, params):
|
|
result = MagicMock()
|
|
tid = params.get("tenant_id", "")
|
|
if tid == DEFAULT_TENANT:
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 3
|
|
result.fetchone.return_value = count_row
|
|
result.fetchall.return_value = [
|
|
make_metric_row(),
|
|
make_metric_row({"id": "bbbbbbbb-0002-0002-0002-000000000002"}),
|
|
make_metric_row({"id": "bbbbbbbb-0003-0003-0003-000000000003"}),
|
|
]
|
|
else:
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 0
|
|
result.fetchone.return_value = count_row
|
|
result.fetchall.return_value = []
|
|
return result
|
|
|
|
mock_db.execute.side_effect = side_effect
|
|
|
|
resp_a = client.get(
|
|
"/quality/metrics",
|
|
headers={"X-Tenant-Id": DEFAULT_TENANT},
|
|
)
|
|
assert resp_a.json()["total"] == 3
|
|
|
|
resp_b = client.get(
|
|
"/quality/metrics",
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert resp_b.json()["total"] == 0
|
|
|
|
def test_tests_tenant_isolation(self, mock_db):
|
|
"""Tenant A sees 2 tests, Tenant B sees 0."""
|
|
def side_effect(query, params):
|
|
result = MagicMock()
|
|
tid = params.get("tenant_id", "")
|
|
if tid == DEFAULT_TENANT:
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 2
|
|
result.fetchone.return_value = count_row
|
|
result.fetchall.return_value = [make_test_row(), make_test_row({"id": "cccccccc-0002-0002-0002-000000000002"})]
|
|
else:
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: 0
|
|
result.fetchone.return_value = count_row
|
|
result.fetchall.return_value = []
|
|
return result
|
|
|
|
mock_db.execute.side_effect = side_effect
|
|
|
|
resp_a = client.get("/quality/tests", headers={"X-Tenant-Id": DEFAULT_TENANT})
|
|
assert resp_a.json()["total"] == 2
|
|
|
|
resp_b = client.get("/quality/tests", headers={"X-Tenant-Id": OTHER_TENANT})
|
|
assert resp_b.json()["total"] == 0
|
|
|
|
def test_invalid_tenant_header_returns_400(self, mock_db):
|
|
response = client.get(
|
|
"/quality/metrics",
|
|
headers={"X-Tenant-Id": "bad-uuid"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_delete_wrong_tenant_returns_404(self, mock_db):
|
|
"""Deleting a metric that belongs to a different tenant returns 404."""
|
|
delete_result = MagicMock()
|
|
delete_result.rowcount = 0
|
|
mock_db.execute.return_value = delete_result
|
|
|
|
response = client.delete(
|
|
f"/quality/metrics/{METRIC_ID}",
|
|
headers={"X-Tenant-Id": OTHER_TENANT},
|
|
)
|
|
assert response.status_code == 404
|