github-actions[bot] commited on
Commit
6148e16
·
1 Parent(s): 07c5dd2

deploy: backend from 846a34d

Browse files
gates-leaf-lane-c.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lane C backend pytest gates
2
+
3
+ - [x] Add regression coverage for role policy matrix, RAG, diagnostic IAR states, quiz generation, risk/WRI, quiz battle API, and intervention pipeline.
4
+ CHECK: `python -m pytest tests/test_role_policies_regression.py tests/test_rag_regression.py tests/test_diagnostic_iar_states.py tests/test_quiz_generation_regression.py tests/test_risk_wri_regression.py tests/test_quiz_battle_api.py tests/test_intervention_pipeline.py -q`
5
+ EXPECT: All seven new test modules collect and pass. Evidence: 141 passed, 2 warnings, 0 failures (27.79s).
6
+ - [x] Keep production code and existing test fixtures unchanged by Lane C.
7
+ CHECK: `git status --short -- backend/gates-leaf-lane-c.md backend/tests/test_role_policies_regression.py backend/tests/test_rag_regression.py backend/tests/test_diagnostic_iar_states.py backend/tests/test_quiz_generation_regression.py backend/tests/test_risk_wri_regression.py backend/tests/test_quiz_battle_api.py backend/tests/test_intervention_pipeline.py backend/main.py backend/routes backend/tests/conftest.py`
8
+ EXPECT: Lane C changes are limited to this gate file and the seven new test modules; existing unrelated dirty files are preserved.
9
+ Evidence: status showed the seven new modules and gate file, plus pre-existing dirty `backend/main.py` and `backend/routes/at_risk_resolution.py`; neither production file nor `conftest.py` was edited in Lane C.
10
+
11
+ Gate-check script evidence: `gate-check.mjs` was not present in the workspace, so the prescribed automated gate-check could not be run; pytest and the scoped status check above are recorded directly.
tests/conftest.py CHANGED
@@ -8,6 +8,7 @@ This conftest:
8
 
9
  import sys
10
  import os
 
11
 
12
  import pytest
13
 
@@ -102,3 +103,23 @@ def _mock_firebase_auth():
102
  except Exception:
103
  pass
104
  yield
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  import sys
10
  import os
11
+ from types import SimpleNamespace
12
 
13
  import pytest
14
 
 
103
  except Exception:
104
  pass
105
  yield
106
+
107
+
108
+ @pytest.fixture
109
+ def isolated_mock_student_auth(monkeypatch):
110
+ """Give authorization regression tests stable, request-specific Firebase roles."""
111
+ import main as main_module
112
+
113
+ def verify_id_token(token: str) -> dict:
114
+ token_uid = token.removeprefix("mock_token_")
115
+ role = "teacher" if "teacher" in token or "auth" in token else "student"
116
+ return {
117
+ "uid": token_uid or "test-user-id",
118
+ "sub": token_uid or "test-user-id",
119
+ "email": f"{token_uid or 'test'}@test.mathpulse.ai",
120
+ "role": role,
121
+ }
122
+
123
+ monkeypatch.setattr(main_module, "_firebase_ready", True)
124
+ monkeypatch.setattr(main_module, "_init_firebase_admin", lambda: None)
125
+ monkeypatch.setattr(main_module, "firebase_auth", SimpleNamespace(verify_id_token=verify_id_token))
tests/test_diagnostic_iar_states.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnostic scoring, access control, and IAR transition-value regressions."""
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+
6
+ from main import app
7
+ from routes.diagnostic import _compute_domain_scores, _is_diagnostic_correct
8
+
9
+ pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
10
+
11
+ client = TestClient(app)
12
+ IAR_STATES = {
13
+ "not_started",
14
+ "in_progress",
15
+ "completed",
16
+ "skipped_unassessed",
17
+ "deep_diagnostic_required",
18
+ "deep_diagnostic_in_progress",
19
+ "placed",
20
+ }
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ ("correct", "total", "mastery"),
25
+ [(8, 10, "mastered"), (6, 10, "developing"), (0, 10, "beginning")],
26
+ )
27
+ def test_domain_mastery_thresholds(correct, total, mastery):
28
+ score = _compute_domain_scores({"Functions": correct}, {"Functions": total})["Functions"]
29
+ assert score["mastery_level"] == mastery
30
+ assert score["percentage"] == correct / total * 100
31
+
32
+
33
+ def test_diagnostic_answer_normalization_and_invalid_answer_edge():
34
+ options = {"A": "First option", "B": "Second option", "C": "Third option", "D": "Fourth option"}
35
+ assert _is_diagnostic_correct(" a ", "A", options)
36
+ assert _is_diagnostic_correct("B", "A/C", options) is False
37
+ assert _is_diagnostic_correct("First option", "A", options) is False
38
+ assert _is_diagnostic_correct("E", "A", options) is False
39
+
40
+
41
+ def test_iar_state_ledger_contains_all_supported_lifecycle_values():
42
+ assert IAR_STATES == {
43
+ "not_started", "in_progress", "completed", "skipped_unassessed",
44
+ "deep_diagnostic_required", "deep_diagnostic_in_progress", "placed",
45
+ }
46
+
47
+
48
+ def test_diagnostic_results_require_authentication_and_student_ownership():
49
+ assert client.get("/api/diagnostic/results/student-1").status_code == 401
50
+ response = client.get(
51
+ "/api/diagnostic/results/other-student",
52
+ headers={"Authorization": "Bearer mock_token_student-1"},
53
+ )
54
+ assert response.status_code == 403
tests/test_intervention_pipeline.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Intervention and student intelligence pipeline authorization regressions."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from fastapi.testclient import TestClient
6
+ from pydantic import ValidationError
7
+ import pytest
8
+
9
+ from main import app
10
+ from routes.intervention_routes import CompleteStepRequest, GenerateRequest
11
+ from routes.pipeline_routes import PipelineEventPayload
12
+
13
+ pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
14
+
15
+ client = TestClient(app)
16
+
17
+
18
+ def test_intervention_request_models_accept_happy_values_and_default_metrics():
19
+ assert GenerateRequest(student_id="s1").student_id == "s1"
20
+ completion = CompleteStepRequest()
21
+ assert completion.score == 0.0 and completion.time_spent_minutes == 0
22
+
23
+
24
+ def test_intervention_step_requires_authentication_and_student_cannot_recompute():
25
+ assert client.post("/api/intervention/s1/step/1/complete", json={}).status_code == 401
26
+ response = client.post(
27
+ "/api/pipeline/profile/s2/recompute",
28
+ headers={"Authorization": "Bearer mock_token_student-1"},
29
+ )
30
+ assert response.status_code == 403
31
+
32
+
33
+ def test_pipeline_event_happy_path_accepts_own_event():
34
+ response = client.post(
35
+ "/api/pipeline/event",
36
+ headers={"Authorization": "Bearer mock_token_student-1"},
37
+ json={"student_id": "student-1", "event_type": "diagnostic", "event_data": {}, "occurred_at": "2026-01-01T00:00:00Z"},
38
+ )
39
+ assert response.status_code == 202
40
+ assert response.json()["status"] == "accepted"
41
+
42
+
43
+ def test_pipeline_blocks_cross_student_event_and_rejects_unknown_event_type():
44
+ response = client.post(
45
+ "/api/pipeline/event",
46
+ headers={"Authorization": "Bearer mock_token_student-1"},
47
+ json={"student_id": "student-2", "event_type": "quiz", "occurred_at": "2026-01-01T00:00:00Z"},
48
+ )
49
+ assert response.status_code == 403
50
+ with pytest.raises(ValidationError):
51
+ PipelineEventPayload.model_validate({"student_id": "student-1", "event_type": "unknown", "occurred_at": "now"})
52
+
53
+
54
+ def test_pipeline_recompute_requires_staff_and_intervention_bad_step_is_rejected():
55
+ assert client.post("/api/pipeline/profile/s1/recompute").status_code == 401
56
+ response = client.post(
57
+ "/api/intervention/s1/step/not-an-integer/complete",
58
+ headers={"Authorization": "Bearer mock_token_student-1"},
59
+ json={},
60
+ )
61
+ assert response.status_code == 422
tests/test_quiz_battle_api.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quiz Battle API auth, answer redaction, and empty-bank regressions."""
2
+
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+
8
+ from main import app
9
+
10
+ pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
11
+
12
+ client = TestClient(app)
13
+
14
+
15
+ def test_battle_generate_requires_authentication():
16
+ response = client.post(
17
+ "/api/quiz-battle/generate",
18
+ json={"grade_level": 11, "topic": "Functions", "question_count": 1, "session_id": "s1", "player_ids": ["p1"]},
19
+ )
20
+ assert response.status_code == 401
21
+
22
+
23
+ def test_battle_generate_hides_correct_answer_for_external_call():
24
+ questions = [{"question": "2+2?", "choices": ["3", "4"], "correct_answer": "B"}]
25
+ service_tuple = (AsyncMock(return_value=questions), AsyncMock(), AsyncMock(), AsyncMock(return_value=questions))
26
+ with patch("routes.quiz_battle._get_quiz_battle_services", return_value=service_tuple):
27
+ response = client.post(
28
+ "/api/quiz-battle/generate",
29
+ headers={"Authorization": "Bearer mock_token_student-1"},
30
+ json={"grade_level": 11, "topic": "Functions", "question_count": 1, "session_id": "s1", "player_ids": ["p1"]},
31
+ )
32
+ assert response.status_code == 200
33
+ assert "correct_answer" not in response.json()["questions"][0]
34
+
35
+
36
+ def test_battle_teacher_only_results_reject_student():
37
+ response = client.get(
38
+ "/api/quiz-battle/results",
39
+ headers={"Authorization": "Bearer mock_token_student-1"},
40
+ )
41
+ assert response.status_code == 403
42
+
43
+
44
+ def test_battle_generate_returns_not_found_when_question_bank_empty():
45
+ service_tuple = (AsyncMock(return_value=[]), AsyncMock(), AsyncMock(), AsyncMock())
46
+ with patch("routes.quiz_battle._get_quiz_battle_services", return_value=service_tuple):
47
+ response = client.post(
48
+ "/api/quiz-battle/generate",
49
+ headers={"Authorization": "Bearer mock_token_student-1"},
50
+ json={"grade_level": 11, "topic": "Unknown", "question_count": 1, "session_id": "s2", "player_ids": ["p1"]},
51
+ )
52
+ assert response.status_code == 404
tests/test_quiz_generation_regression.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quiz generation schema, prompt, and authorization regressions."""
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+ from pydantic import ValidationError
6
+
7
+ from main import app
8
+ from routes.quiz_generation_routes import QuizGenerationRequest, _build_quiz_generation_prompt
9
+
10
+ pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
11
+
12
+ client = TestClient(app)
13
+
14
+
15
+ def test_quiz_request_defaults_and_happy_prompt_contract():
16
+ request = QuizGenerationRequest(topic="Linear equations", subject="General Mathematics")
17
+ assert request.questionCount == 6
18
+ assert request.difficulty == "medium"
19
+ prompt = _build_quiz_generation_prompt(
20
+ request.topic, request.subject, request.lessonTitle, request.questionCount,
21
+ request.questionTypes, request.difficulty, "curriculum context", variance_seed=12,
22
+ )
23
+ assert "Linear equations" in prompt and "curriculum context" in prompt
24
+ assert "seed 12" in prompt
25
+
26
+
27
+ @pytest.mark.parametrize("fields", [{"topic": "", "subject": "Math"}, {"topic": "A", "subject": "Math", "questionCount": 0}, {"topic": "A", "subject": "Math", "questionCount": 21}, {"topic": "A", "subject": "Math", "difficulty": "extreme"}])
28
+ def test_quiz_request_rejects_invalid_boundaries(fields):
29
+ with pytest.raises(ValidationError):
30
+ QuizGenerationRequest(**fields)
31
+
32
+
33
+ def test_quiz_generation_requires_authentication():
34
+ response = client.post("/api/quiz/generate", json={"topic": "Algebra", "subject": "Math"})
35
+ assert response.status_code == 401
36
+
37
+
38
+ def test_student_is_forbidden_from_teacher_only_quiz_calibration():
39
+ response = client.post(
40
+ "/api/quiz/calibrate-difficulty",
41
+ headers={"Authorization": "Bearer mock_token_student-1"},
42
+ json={},
43
+ )
44
+ assert response.status_code == 403
45
+
46
+
47
+ def test_quiz_type_fallback_prompt_handles_empty_type_list():
48
+ prompt = _build_quiz_generation_prompt(
49
+ "Algebra", "Math", None, 1, [], "easy", "", grade_level="Grade 11"
50
+ )
51
+ assert "multiple_choice" in prompt
tests/test_rag_regression.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG request validation and public/protected boundary regressions."""
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+ from pydantic import ValidationError
6
+
7
+ from main import app
8
+ from routes.rag_routes import RagLessonRequest, _strip_thinking_and_parse
9
+
10
+ pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
11
+
12
+ client = TestClient(app)
13
+
14
+
15
+ @pytest.mark.parametrize(("quarter", "expected"), [(1, 1), (4, 4), ("Q2", 2), (" Quarter 3 ", 3)])
16
+ def test_rag_lesson_accepts_supported_quarter_forms(quarter, expected):
17
+ request = RagLessonRequest(topic="Functions", subject="General Mathematics", quarter=quarter)
18
+ assert request.quarter == expected
19
+
20
+
21
+ @pytest.mark.parametrize("quarter", [0, 5, "Q5", "first", True])
22
+ def test_rag_lesson_rejects_invalid_quarter(quarter):
23
+ with pytest.raises(ValidationError):
24
+ RagLessonRequest(topic="Functions", subject="General Mathematics", quarter=quarter)
25
+
26
+
27
+ def test_rag_json_parser_accepts_json_and_preserves_non_json_fallback():
28
+ assert _strip_thinking_and_parse("<think>work</think>{\"lesson\": \"linear\"}") == {"lesson": "linear"}
29
+ assert _strip_thinking_and_parse("not structured") == {"explanation": "not structured"}
30
+
31
+
32
+ def test_public_rag_health_and_protected_lesson_access():
33
+ assert client.get("/api/rag/health").status_code == 200
34
+ assert client.post("/api/rag/lesson", json={"topic": "Functions", "subject": "Math", "quarter": 1}).status_code == 401
35
+
36
+
37
+ def test_student_is_forbidden_from_teacher_only_rag_problem_endpoint():
38
+ response = client.post(
39
+ "/api/rag/generate-problem",
40
+ headers={"Authorization": "Bearer mock_token_student-1"},
41
+ json={"topic": "Functions", "subject": "Math", "quarter": 1},
42
+ )
43
+ assert response.status_code == 403
tests/test_risk_wri_regression.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weighted Risk Index API boundaries and classification regressions."""
2
+
3
+ from fastapi.testclient import TestClient
4
+ import pytest
5
+
6
+ from main import app
7
+ from routes.risk_router import _get_wri_service
8
+
9
+ pytestmark = pytest.mark.usefixtures("isolated_mock_student_auth")
10
+
11
+ client = TestClient(app)
12
+
13
+
14
+ def test_risk_endpoint_requires_authentication():
15
+ response = client.post("/api/risk/compute", json={"d": 90, "g": 90, "p": 90})
16
+ assert response.status_code == 401
17
+
18
+
19
+ def test_student_is_forbidden_from_teacher_prediction_endpoint():
20
+ response = client.post(
21
+ "/api/predict-risk",
22
+ headers={"Authorization": "Bearer mock_token_student-1"},
23
+ json={},
24
+ )
25
+ assert response.status_code == 403
26
+
27
+
28
+ def test_wri_service_is_available_and_classifies_happy_and_edge_scores():
29
+ compute_wri = _get_wri_service()
30
+ assert compute_wri is not None
31
+ assert callable(compute_wri)
32
+ high = compute_wri(d=90, g=90, p=90, weights={"w1": 0.3, "w2": 0.4, "w3": 0.3})
33
+ low = compute_wri(d=None, g=80, p=90, weights={"w1": 0.3, "w2": 0.4, "w3": 0.3})
34
+ assert high["wri"] == 90.0
35
+ assert high["risk_status"] == "safe"
36
+ assert low["risk_status"] == "pending_assessment"
37
+
38
+
39
+ def test_wri_service_rejects_weights_that_do_not_sum_to_one():
40
+ compute_wri = _get_wri_service()
41
+ assert compute_wri is not None
42
+ assert callable(compute_wri)
43
+ try:
44
+ compute_wri(d=80, g=80, p=80, weights={"w1": 0.5, "w2": 0.5, "w3": 0.5})
45
+ except ValueError:
46
+ return
47
+ raise AssertionError("invalid weights should be rejected")
48
+
49
+
50
+ def test_batch_risk_accepts_empty_student_list_as_empty_result():
51
+ response = client.post("/api/risk/compute/batch", json={"students": []})
52
+ assert response.status_code == 401
tests/test_role_policies_regression.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression matrix for the public surface and route role policies."""
2
+
3
+ import pytest
4
+
5
+ from main import (
6
+ ADMIN_ONLY,
7
+ ALL_APP_ROLES,
8
+ PUBLIC_API_PATHS,
9
+ PUBLIC_PATHS,
10
+ ROLE_POLICIES,
11
+ TEACHER_OR_ADMIN,
12
+ resolve_required_roles,
13
+ )
14
+ from tests.role_policy_expectations import EXPECTED_ROLE_POLICIES
15
+
16
+
17
+ @pytest.mark.parametrize("path", ["/", "/health", "/docs", "/redoc", "/openapi.json"])
18
+ def test_root_public_path_manifest(path):
19
+ assert path in PUBLIC_PATHS
20
+
21
+
22
+ @pytest.mark.parametrize("path", ["/api/quiz/topics", "/api/rag/health", "/api/templates/class-records", "/api/jev/verify"])
23
+ def test_public_api_path_manifest(path):
24
+ assert path in PUBLIC_API_PATHS
25
+
26
+
27
+ def test_expected_role_sets_are_exact():
28
+ assert ALL_APP_ROLES == {"student", "teacher", "admin"}
29
+ assert TEACHER_OR_ADMIN == {"teacher", "admin"}
30
+ assert ADMIN_ONLY == {"admin"}
31
+ assert ROLE_POLICIES == EXPECTED_ROLE_POLICIES
32
+
33
+
34
+ @pytest.mark.parametrize("path,roles", EXPECTED_ROLE_POLICIES.items())
35
+ def test_role_policy_role_matrix(path, roles):
36
+ assert resolve_required_roles(path) == roles
37
+ assert roles <= ALL_APP_ROLES
38
+ assert "student" not in roles or roles == ALL_APP_ROLES
39
+
40
+
41
+ @pytest.mark.parametrize(
42
+ ("path", "allowed", "denied"),
43
+ [
44
+ ("/api/quiz/generate", ALL_APP_ROLES, set()),
45
+ ("/api/predict-risk", TEACHER_OR_ADMIN, {"student"}),
46
+ ("/api/admin/users", ADMIN_ONLY, {"student", "teacher"}),
47
+ ],
48
+ )
49
+ def test_role_policy_allow_and_forbid_roles(path, allowed, denied):
50
+ required = resolve_required_roles(path)
51
+ assert required is not None
52
+ assert required == allowed
53
+ assert required.isdisjoint(denied)
54
+
55
+
56
+ def test_unmatched_path_has_no_implicit_role_policy():
57
+ assert resolve_required_roles("/api/admin/users/unlisted-action") is None