shinkan-jinkendo/backend/tests/test_exercise_enrichment_admin.py
Lars f4196c3580
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m17s
Add Exercise Enrichment Admin API and Update Documentation
- Introduced the `exercise_enrichment_admin` API for batch exercise enrichment, allowing superadmins to filter candidates, preview, and apply skills.
- Updated the access layer documentation to include the new endpoint and its exempt status.
- Enhanced the frontend with a new admin page for exercise enrichment and updated navigation to include this feature.
- Incremented version to 0.8.179 and updated changelog to reflect these additions and improvements.
2026-05-23 07:35:45 +02:00

283 lines
8.4 KiB
Python

"""Superadmin Übungs-Anreicherung — Auth, Merge-Logik, Status, Analyze."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from exercise_enrichment import (
apply_exercise_enrichment,
compute_skill_diff,
estimate_llm_calls,
merge_skills,
persist_merged_skills,
validate_exercise_for_enrichment,
)
from main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(require_auth, None)
def test_candidates_requires_superadmin(client: TestClient) -> None:
def _admin():
return {"profile_id": 1, "role": "admin"}
app.dependency_overrides[require_auth] = _admin
r = client.get("/api/admin/exercise-enrichment/candidates", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_preview_requires_superadmin(client: TestClient) -> None:
def _trainer():
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = _trainer
r = client.post(
"/api/admin/exercise-enrichment/preview",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"exercise_ids": [1], "modes": {"skills": True}},
)
assert r.status_code == 403
@patch("routers.exercise_enrichment_admin.get_db")
def test_candidates_ok_for_superadmin(mock_get_db, client: TestClient) -> None:
def _super():
return {"profile_id": 1, "role": "superadmin"}
app.dependency_overrides[require_auth] = _super
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [{"c": 2}, None]
mock_cur.fetchall.return_value = [
{
"id": 10,
"title": "Kata Basics",
"status": "draft",
"visibility": "private",
"summary": "",
"updated_at": "2026-05-23T10:00:00",
"primary_focus_name": "Karate",
"skill_count": 0,
"ai_suggested_skill_count": 0,
}
]
mock_get_db.return_value = mock_cm
with patch("routers.exercise_enrichment_admin.get_cursor", return_value=mock_cur):
r = client.get(
"/api/admin/exercise-enrichment/candidates?status=draft&without_skills=true",
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert len(body["items"]) == 1
assert body["items"][0]["id"] == 10
def test_analyze_returns_llm_estimate(client: TestClient) -> None:
def _super():
return {"profile_id": 1, "role": "superadmin"}
app.dependency_overrides[require_auth] = _super
r = client.post(
"/api/admin/exercise-enrichment/analyze",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"exercise_ids": [1, 2, 3],
"modes": {"skills": True, "summary": True, "instructions": False},
},
)
assert r.status_code == 200
body = r.json()
assert body["exercise_count"] == 3
est = body["estimated_llm_calls"]
assert est["total"] == 6
assert est["skills"] == 3
assert est["summary"] == 3
def test_validate_exercise_requires_title_and_content() -> None:
assert (
validate_exercise_for_enrichment({"title": "", "goal": "<p>x</p>"}, want_skills=True)
== "Titel fehlt"
)
assert (
validate_exercise_for_enrichment({"title": "Foo", "goal": "", "execution": ""}, want_skills=True)
== "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
)
assert validate_exercise_for_enrichment({"title": "Foo", "goal": "<p>Ziel</p>"}, want_skills=True) is None
def test_merge_skills_additive_keeps_manual() -> None:
existing = [
{
"skill_id": 1,
"skill_name": "Manual",
"intensity": "hoch",
"required_level": "aufbau",
"target_level": "fortgeschritten",
"is_primary": True,
"ai_suggested": False,
}
]
suggested = [
{
"skill_id": 1,
"skill_name": "Manual AI",
"intensity": "niedrig",
"required_level": "basis",
"target_level": "grundlagen",
"is_primary": False,
},
{
"skill_id": 2,
"skill_name": "New AI",
"intensity": "mittel",
"required_level": "grundlagen",
"target_level": "aufbau",
"is_primary": False,
},
]
merged = merge_skills(existing, suggested, "additive")
assert len(merged) == 2
manual = next(s for s in merged if s["skill_id"] == 1)
assert manual["intensity"] == "hoch"
assert manual["ai_suggested"] is False
ai_new = next(s for s in merged if s["skill_id"] == 2)
assert ai_new["ai_suggested"] is True
assert ai_new["intensity"] == "mittel"
def test_merge_skills_replace_all_marks_ai() -> None:
existing = [
{"skill_id": 1, "skill_name": "M", "intensity": "hoch", "ai_suggested": False},
]
suggested = [
{"skill_id": 3, "skill_name": "New AI", "intensity": "niedrig", "required_level": "basis", "target_level": "aufbau"},
]
merged = merge_skills(existing, suggested, "replace_all")
assert len(merged) == 1
assert merged[0]["skill_id"] == 3
assert merged[0]["ai_suggested"] is True
assert merged[0]["intensity"] == "niedrig"
def test_compute_skill_diff_added_and_removed() -> None:
before = [{"skill_id": 1, "skill_name": "A", "intensity": "mittel", "ai_suggested": False}]
after = [
{"skill_id": 1, "skill_name": "A", "intensity": "mittel", "ai_suggested": False},
{"skill_id": 2, "skill_name": "B", "intensity": "hoch", "ai_suggested": True},
]
diff = compute_skill_diff(before, after)
assert len(diff["added"]) == 1
assert diff["added"][0]["skill_id"] == 2
@patch("exercise_enrichment.enrich_exercise_detail")
def test_apply_sets_in_review(mock_enrich) -> None:
mock_enrich.return_value = {
"id": 5,
"title": "Test",
"goal": "<p>G</p>",
"execution": "",
"status": "draft",
"skills": [],
}
mock_cur = MagicMock()
row = apply_exercise_enrichment(
mock_cur,
5,
merged_skills=[
{
"skill_id": 9,
"skill_name": "Kick",
"intensity": "mittel",
"required_level": "grundlagen",
"target_level": "aufbau",
"is_primary": True,
"ai_suggested": True,
}
],
merge_mode="replace_all",
set_status="in_review",
apply_skills=True,
)
assert row["ok"] is True
assert row["status"] == "in_review"
assert mock_cur.execute.call_count >= 2
def test_apply_rejects_approved_status() -> None:
mock_cur = MagicMock()
with patch(
"exercise_enrichment.enrich_exercise_detail",
return_value={
"title": "T",
"goal": "<p>G</p>",
"status": "draft",
"skills": [],
},
):
row = apply_exercise_enrichment(
mock_cur,
1,
merged_skills=[{"skill_id": 1, "intensity": "mittel", "ai_suggested": True}],
set_status="approved",
apply_skills=True,
)
assert row["ok"] is False
assert "approved" in row["error"]
def test_persist_merged_skills_uses_upsert() -> None:
mock_cur = MagicMock()
persist_merged_skills(
mock_cur,
7,
[
{
"skill_id": 3,
"intensity": "mittel",
"required_level": "grundlagen",
"target_level": "aufbau",
"is_primary": False,
"ai_suggested": True,
}
],
"replace_all",
)
sql = mock_cur.execute.call_args_list[-1][0][0]
assert "INSERT INTO exercise_skills" in sql
def test_estimate_llm_calls_breakdown() -> None:
est = estimate_llm_calls(
exercise_count=100,
want_skills=True,
want_summary=False,
want_instructions=True,
)
assert est["total"] == 200
assert est["per_exercise"] == 2