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
- 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.
283 lines
8.4 KiB
Python
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
|