Checkpoint React frontend migration

This commit is contained in:
Dwindi Ramadhana
2026-06-20 01:43:39 +07:00
parent ab86c254d1
commit b8e201b45f
173 changed files with 34116 additions and 782 deletions

View File

@@ -0,0 +1,42 @@
from pathlib import Path
import sys
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.auth import ( # noqa: E402
AuthContext,
ensure_website_scope_matches,
require_website_auth,
)
def test_require_website_auth_returns_scoped_website_for_allowed_role():
auth = AuthContext(website_id=5, role="admin", wp_user_id=None)
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
assert website_id == 5
def test_require_website_auth_allows_global_system_admin_scope():
auth = AuthContext(website_id=None, role="system_admin", wp_user_id=None)
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
assert website_id is None
def test_require_website_auth_rejects_disallowed_role():
auth = AuthContext(website_id=5, role="student", wp_user_id="u1")
with pytest.raises(HTTPException) as exc_info:
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
assert exc_info.value.status_code == 403
def test_cross_website_payload_mismatch_is_blocked():
with pytest.raises(HTTPException) as exc_info:
ensure_website_scope_matches(auth_website_id=10, payload_website_id=11)
assert exc_info.value.status_code == 403
def test_global_system_admin_scope_can_write_any_payload_website():
ensure_website_scope_matches(auth_website_id=None, payload_website_id=11)

View File

@@ -0,0 +1,74 @@
from pathlib import Path
import sys
import time
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.auth import decode_access_token, issue_access_token # noqa: E402
def test_issue_and_decode_access_token_round_trip():
token = issue_access_token(
website_id=42,
role="student",
wp_user_id="wp-1001",
expires_in_seconds=3600,
)
auth = decode_access_token(token)
assert auth.website_id == 42
assert auth.role == "student"
assert auth.wp_user_id == "wp-1001"
def test_system_admin_token_can_be_global_without_website_scope():
token = issue_access_token(
website_id=None,
role="system_admin",
wp_user_id=None,
expires_in_seconds=3600,
)
auth = decode_access_token(token)
assert auth.website_id is None
assert auth.role == "system_admin"
def test_non_system_admin_token_requires_website_scope():
token = issue_access_token(
website_id=None,
role="admin",
wp_user_id=None,
expires_in_seconds=3600,
)
with pytest.raises(HTTPException) as exc_info:
decode_access_token(token)
assert exc_info.value.status_code == 401
def test_decode_access_token_rejects_tampered_signature():
token = issue_access_token(
website_id=7,
role="admin",
wp_user_id=None,
expires_in_seconds=3600,
)
payload, signature = token.split(".", 1)
tampered_token = f"{payload}.{'A' if signature[0] != 'A' else 'B'}{signature[1:]}"
with pytest.raises(HTTPException) as exc_info:
decode_access_token(tampered_token)
assert exc_info.value.status_code == 401
def test_decode_access_token_rejects_expired_token():
token = issue_access_token(
website_id=9,
role="student",
wp_user_id="u-1",
expires_in_seconds=-1,
)
time.sleep(0.01)
with pytest.raises(HTTPException) as exc_info:
decode_access_token(token)
assert exc_info.value.status_code == 401

View File

@@ -0,0 +1,12 @@
from sqlalchemy.orm import configure_mappers
def test_sqlalchemy_mappers_configure_without_join_errors():
"""
Ensure relationship joins are fully resolvable.
This catches missing FK/primaryjoin regressions early.
"""
import app.models # noqa: F401
configure_mappers()

View File

@@ -0,0 +1,77 @@
import math
import os
import sys
import pytest
# Ensure project root is importable when tests run in isolated environments.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.services.normalization import apply_normalization
@pytest.mark.parametrize(
("nm", "rataan", "sb", "expected"),
[
(500, 500, 100, 500),
(600, 500, 100, 600),
(400, 500, 100, 400),
(1000, 500, 100, 1000),
(0, 500, 100, 0),
(500, 600, 80, 375),
],
)
def test_apply_normalization_nominal_cases(nm: int, rataan: float, sb: float, expected: int):
assert apply_normalization(nm, rataan, sb) == expected
@pytest.mark.parametrize("nm", [-1, 1001, 1500, -100])
def test_apply_normalization_rejects_invalid_nm(nm: int):
with pytest.raises(ValueError):
apply_normalization(nm, 500, 100)
@pytest.mark.parametrize("sb", [0, -1, -100.0])
def test_apply_normalization_returns_default_when_sd_non_positive(sb: float):
assert apply_normalization(500, 500, sb) == 500
def test_dynamic_normalization_distribution_behaves_as_expected():
nm_scores = [450, 480, 500, 520, 550, 480, 510, 490, 530, 470]
mean = sum(nm_scores) / len(nm_scores)
variance = sum((x - mean) ** 2 for x in nm_scores) / len(nm_scores)
std = math.sqrt(variance)
nn_scores = [apply_normalization(nm, mean, std) for nm in nm_scores]
nn_mean = sum(nn_scores) / len(nn_scores)
nn_variance = sum((x - nn_mean) ** 2 for x in nn_scores) / len(nn_scores)
nn_std = math.sqrt(nn_variance)
# Rounding in apply_normalization introduces small drift; these bounds are tight.
assert abs(nn_mean - 500) <= 5
assert abs(nn_std - 100) <= 5
def test_incremental_population_stats_match_batch_stats():
scores = [500, 550, 450, 600, 400]
participant_count = 0
total_nm_sum = 0.0
total_nm_sq_sum = 0.0
for score in scores:
participant_count += 1
total_nm_sum += score
total_nm_sq_sum += score * score
incremental_mean = total_nm_sum / participant_count
incremental_variance = (total_nm_sq_sum / participant_count) - (incremental_mean**2)
incremental_std = math.sqrt(max(0.0, incremental_variance))
batch_mean = sum(scores) / len(scores)
batch_variance = sum((x - batch_mean) ** 2 for x in scores) / len(scores)
batch_std = math.sqrt(batch_variance)
assert incremental_mean == pytest.approx(batch_mean, rel=0, abs=1e-10)
assert incremental_std == pytest.approx(batch_std, rel=0, abs=1e-10)

View File

@@ -0,0 +1,271 @@
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.core import rate_limit
from app.core.config import Settings
from app.models.report_schedule import ReportScheduleModel
from app.services import ai_generation
from app.services import cat_selection
from app.services.reporting import (
cancel_scheduled_report,
get_scheduled_report,
list_scheduled_reports,
schedule_report,
)
from app.schemas.ai import GeneratedQuestion
class DummyRequest:
client = SimpleNamespace(host="127.0.0.1")
class DummyScalarResult:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
def scalar(self):
return self._value
class DummyScalars:
def __init__(self, values):
self._values = values
def all(self):
return self._values
class DummyListResult:
def __init__(self, values):
self._values = values
def scalars(self):
return DummyScalars(self._values)
class DummyRowsResult:
def __init__(self, values):
self._values = values
def all(self):
return self._values
class DummyDb:
def __init__(self, execute_results=None):
self.execute_results = list(execute_results or [])
self.added = []
self.flushed = False
def add(self, row):
self.added.append(row)
async def flush(self):
self.flushed = True
async def execute(self, _query):
return self.execute_results.pop(0)
class DummyRedis:
def __init__(self):
self.calls = 0
async def incr(self, _key):
self.calls += 1
return self.calls
async def expire(self, _key, _seconds):
return True
async def ttl(self, _key):
return 60
def test_ai_stats_accepts_website_scope(monkeypatch):
captured_queries = []
class CaptureDb:
async def execute(self, query):
captured_queries.append(str(query))
if len(captured_queries) == 1:
return DummyScalarResult(0)
return DummyRowsResult([])
asyncio.run(ai_generation.get_ai_stats(CaptureDb(), website_id=9))
assert all("items.website_id" in query for query in captured_queries)
def test_ai_prompt_preserves_basis_option_labels():
prompt = ai_generation.get_prompt_template(
basis_stem="<p>Basis question?</p>",
basis_options={
"A": "Option A",
"B": "Option B",
"C": "Option C",
"D": "Option D",
"E": "Option E",
},
basis_correct="A",
basis_explanation="<p>Because A.</p>",
target_level="mudah",
)
assert "Create exactly 5 answer options with labels exactly: A, B, C, D, E" in prompt
assert '"E": "Option E text"' in prompt
assert "The correct field must be exactly one of: A, B, C, D, E" in prompt
def test_generated_question_must_match_basis_option_labels():
basis_item = SimpleNamespace(
options={
"A": "Option A",
"B": "Option B",
"C": "Option C",
"D": "Option D",
"E": "Option E",
}
)
generated = GeneratedQuestion(
stem="Generated",
options={
"A": "Option A",
"B": "Option B",
"C": "Option C",
"D": "Option D",
},
correct="A",
)
assert not ai_generation.generated_matches_basis_options(generated, basis_item)
def test_cat_selection_only_serves_active_or_approved_variants():
compiled = str(
cat_selection._servable_item_filter().compile(
compile_kwargs={"literal_binds": True}
)
)
assert "active" in compiled
assert "approved" in compiled
assert "draft" not in compiled
assert "rejected" not in compiled
def test_production_init_db_skips_create_all(monkeypatch):
import app.database as database
class ExplodingEngine:
def begin(self):
raise AssertionError("create_all should not run in production")
monkeypatch.setattr(database, "settings", Settings(ENVIRONMENT="production"))
monkeypatch.setattr(database, "engine", ExplodingEngine())
asyncio.run(database.init_db())
def test_rate_limit_uses_redis_and_blocks_when_limit_exceeded(monkeypatch):
dummy_redis = DummyRedis()
rate_limit.reset_rate_limit_state()
monkeypatch.setattr(rate_limit, "_get_redis_client", lambda: dummy_redis)
asyncio.run(
rate_limit.enforce_rate_limit(
DummyRequest(),
scope="test.redis",
max_requests=1,
window_seconds=60,
)
)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
rate_limit.enforce_rate_limit(
DummyRequest(),
scope="test.redis",
max_requests=1,
window_seconds=60,
)
)
assert exc_info.value.status_code == 429
def test_rate_limit_falls_back_to_memory_when_redis_unavailable(monkeypatch):
rate_limit.reset_rate_limit_state()
monkeypatch.setattr(rate_limit, "_get_redis_client", lambda: None)
asyncio.run(
rate_limit.enforce_rate_limit(
DummyRequest(),
scope="test.memory",
max_requests=1,
window_seconds=60,
)
)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
rate_limit.enforce_rate_limit(
DummyRequest(),
scope="test.memory",
max_requests=1,
window_seconds=60,
)
)
assert exc_info.value.status_code == 429
def test_schedule_report_persists_model_row():
db = DummyDb()
schedule_id = asyncio.run(
schedule_report(
db,
report_type="student_performance",
schedule="daily",
tryout_ids=["t1"],
website_id=3,
recipients=["ops@example.com"],
export_format="xlsx",
)
)
assert db.flushed is True
assert isinstance(db.added[0], ReportScheduleModel)
assert db.added[0].schedule_id == schedule_id
assert db.added[0].website_id == 3
def test_schedule_helpers_read_list_and_soft_cancel():
row = ReportScheduleModel(
schedule_id="sched-1",
report_type="student_performance",
schedule="daily",
tryout_ids=["t1"],
website_id=3,
recipients=["ops@example.com"],
format="xlsx",
is_active=True,
)
get_db = DummyDb([DummyScalarResult(row)])
listed_db = DummyDb([DummyListResult([row])])
cancel_db = DummyDb([DummyScalarResult(row)])
got = asyncio.run(get_scheduled_report(get_db, "sched-1"))
listed = asyncio.run(list_scheduled_reports(listed_db, website_id=3))
cancelled = asyncio.run(cancel_scheduled_report(cancel_db, "sched-1"))
assert got.schedule_id == "sched-1"
assert listed[0].website_id == 3
assert cancelled is True
assert row.is_active is False

View File

@@ -0,0 +1,11 @@
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.main import app
def test_next_item_route_is_registered():
paths = set(app.openapi()["paths"])
assert "/api/v1/session/{session_id}/next_item" in paths

View File

@@ -0,0 +1,132 @@
import asyncio
import inspect
from datetime import datetime, timezone
from fastapi.params import Depends
from app.api.v1.session import SubmitAnswerResponse
from app.core.auth import AuthContext, get_auth_context
from app.routers import admin as admin_router
from app.routers import reports as reports_router
from app.routers import wordpress as wordpress_router
from app.schemas.session import SessionCompleteResponse, UserAnswerOutput
from app.services.reporting import AggregatePerformanceStats, StudentPerformanceReport
def _depends_on_auth(callable_obj, parameter_name: str = "auth") -> bool:
parameter = inspect.signature(callable_obj).parameters[parameter_name]
default = parameter.default
return isinstance(default, Depends) and default.dependency is get_auth_context
def test_admin_actions_require_signed_auth_context():
assert _depends_on_auth(admin_router.admin_trigger_calibration)
assert _depends_on_auth(admin_router.admin_toggle_ai_generation)
assert _depends_on_auth(admin_router.admin_reset_normalization)
def test_wordpress_user_lookup_routes_require_signed_auth_context():
assert _depends_on_auth(wordpress_router.get_website_users)
assert _depends_on_auth(wordpress_router.get_user_endpoint)
def test_wordpress_roles_map_to_api_admin_roles():
assert wordpress_router._api_role_from_wordpress_roles(["subscriber"]) == "student"
assert wordpress_router._api_role_from_wordpress_roles(["administrator"]) == "admin"
assert wordpress_router._api_role_from_wordpress_roles(["super_admin"]) == "system_admin"
def test_adaptive_submit_response_does_not_expose_answer_key_or_correctness():
payload = SubmitAnswerResponse(theta=0.12, theta_se=0.8).model_dump()
assert "is_correct" not in payload
assert "correct_answer" not in payload
assert "explanation" not in payload
def test_session_completion_answer_output_does_not_expose_correctness():
answer_payload = UserAnswerOutput(
id=1,
item_id=10,
response="A",
time_spent=12,
bobot_earned=0.5,
scoring_mode_used="ctt",
).model_dump()
assert "is_correct" not in answer_payload
response_payload = SessionCompleteResponse(
id=1,
session_id="s-1",
wp_user_id="wp-1",
website_id=2,
tryout_id="tryout-1",
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc),
is_completed=True,
scoring_mode_used="ctt",
total_benar=1,
total_bobot_earned=0.5,
NM=500,
NN=500,
rataan_used=500,
sb_used=100,
user_answers=[
UserAnswerOutput(
id=1,
item_id=10,
response="A",
time_spent=12,
bobot_earned=0.5,
scoring_mode_used="ctt",
)
],
).model_dump()
assert "is_correct" not in response_payload["user_answers"][0]
def test_student_performance_report_is_scoped_to_student_user(monkeypatch):
captured = {}
async def fake_generate_student_performance_report(**kwargs):
captured.update(kwargs)
return StudentPerformanceReport(
generated_at=datetime.now(timezone.utc),
tryout_id=kwargs["tryout_id"],
website_id=kwargs["website_id"],
date_range=kwargs["date_range"],
aggregate=AggregatePerformanceStats(
tryout_id=kwargs["tryout_id"],
participant_count=0,
avg_nm=None,
std_nm=None,
min_nm=None,
max_nm=None,
median_nm=None,
avg_nn=None,
std_nn=None,
avg_theta=None,
pass_rate=0.0,
avg_time_spent=0.0,
),
individual_records=[],
)
monkeypatch.setattr(
reports_router,
"generate_student_performance_report",
fake_generate_student_performance_report,
)
asyncio.run(
reports_router.get_student_performance_report(
tryout_id="tryout-1",
db=object(),
auth=AuthContext(website_id=5, role="student", wp_user_id="wp-1"),
)
)
assert captured["website_id"] == 5
assert captured["wp_user_id"] == "wp-1"

View File

@@ -0,0 +1,110 @@
import asyncio
from types import SimpleNamespace
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.services.tryout_json_import import preview_tryout_json_import
class DummyScalarResult:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
class DummyScalars:
def __init__(self, values):
self._values = values
def all(self):
return self._values
class DummyListResult:
def __init__(self, values):
self._values = values
def scalars(self):
return DummyScalars(self._values)
class DummySession:
def __init__(self, responses):
self._responses = list(responses)
async def execute(self, _query):
return self._responses.pop(0)
def test_preview_tryout_json_import_classifies_new_updated_and_removed_questions():
payload = {
"export_info": {
"exported_at": "2026-04-02 09:12:59",
"exported_by": "Admin",
"tryout_id": 1038,
},
"tryouts": {
"tryout_1038": {
"info": {
"id": 1038,
"title": "Tryout PPDS Obgyn",
"permalink": "https://member.example.com/tryout/1038",
},
"questions": [
{
"id": 269,
"title": "Question A",
"question": "<p>Question A body</p>",
"options": [
{"increment": "A", "label": "", "value": "0"},
{"increment": "B", "label": "", "value": "1"},
],
"answer": "B",
"explanation": "<p>Because.</p>",
},
{
"id": 270,
"title": "Question B new",
"question": "<p>Question B body</p>",
"options": [
{"increment": "A", "label": "", "value": "1"},
{"increment": "B", "label": "", "value": "0"},
],
"answer": "A",
"explanation": "<p>New item.</p>",
},
],
"results": [],
}
},
}
existing_question = SimpleNamespace(
source_question_id="269",
content_checksum="old-checksum",
is_active=True,
)
removed_question = SimpleNamespace(
source_question_id="999",
content_checksum="removed-checksum",
is_active=True,
)
db = DummySession(
[
DummyScalarResult(SimpleNamespace(id=1)),
DummyListResult([existing_question, removed_question]),
]
)
preview = asyncio.run(preview_tryout_json_import(payload, website_id=1, db=db))
assert preview["tryout_count"] == 1
assert preview["totals"]["new_questions"] == 1
assert preview["totals"]["updated_questions"] == 1
assert preview["totals"]["removed_questions"] == 1
assert preview["totals"]["missing_option_labels"] == 2
assert "read-only reference data" not in str(preview)