elemes/services/tests/test_resume_quiz.py

417 lines
15 KiB
Python
Raw Permalink Normal View History

feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan Kebijakan termination tetap strict, tapi guru pegang override untuk kendala tak terduga (false-positive blur dari dialog OS, browser crash, refresh tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan. - Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin guru melanjutkan attempt terminated - Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa) - Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted ditolak karena kuis sudah selesai normal - has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt terbuka selama sesi lanjutan - finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru) lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal - Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt terminated - Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per question_id, is_correct dihitung ulang dari source sebagai guard bila konten soal berubah; option_id tak dikenal dianggap belum dijawab - Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan; resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat diuji Vitest - UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul). Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut guru tidak memulai sesi yang jawabannya akan terbuang - Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit quiz-session, 10 unit quiz-view - Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di 07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
"""
Kontrak route resume-quiz-attempt (izin guru melanjutkan attempt terminated):
- teacher-only via teacher_token + student_id (bukan token siswa);
- hanya attempt `terminated` yang bisa di-resume (submitted ditolak);
- setelah resume: gate one-attempt terbuka (has_quiz_attempt False) dan
siswa dapat menyelesaikan ulang kuis dengan attempt_id SAMA — row attempt
ditimpa sekali, resumed_at di-clear, progress di-update; retry idempotent;
- report guru menampilkan field `{slug}_resumed_at`.
Integrasi PostgreSQL (butuh DATABASE_URL) — backend CSV sudah dicabut.
"""
import os
import uuid
import pytest
from services.tests.conftest import STUDENT_TOKEN, TEACHER_TOKEN
pytestmark = [
pytest.mark.skipif(
not os.environ.get("DATABASE_URL"), reason="butuh PostgreSQL nyata"
),
pytest.mark.integration,
]
LESSON = "hello_world"
@pytest.fixture(autouse=True)
def _seed(seed_demo_users):
yield
def _post_quiz(client, attempt_id, *, status, reason, score, answers=None):
return client.post(
"/lesson-progress",
json={
"token": STUDENT_TOKEN,
"lesson_name": LESSON,
"type": "quiz",
"attempt_id": attempt_id,
"status": status,
"termination_reason": reason,
"score": score,
"occurred_at": "2026-08-09T14:04:44.000Z",
"started_at": "2026-08-09T14:03:00.000Z",
"visibility_event_count": 1,
"answers": answers or [],
},
)
def _post_resume(client, student_id, lesson=LESSON):
return client.post(
"/resume-quiz-attempt",
json={
"teacher_token": TEACHER_TOKEN,
"student_id": student_id,
"lesson_name": lesson,
},
)
def _student_id():
from services import repositories as repo
from services.database import SessionLocal
db = SessionLocal()
try:
return repo.find_user_by_raw_token(db, STUDENT_TOKEN).id
finally:
db.close()
# ── auth & validasi ─────────────────────────────────────────────
def test_resume_requires_teacher(client):
resp = client.post(
"/resume-quiz-attempt",
json={
"teacher_token": STUDENT_TOKEN,
"student_id": _student_id(),
"lesson_name": LESSON,
},
)
assert resp.status_code == 401
assert resp.get_json()["success"] is False
def test_resume_requires_fields(client):
resp = client.post(
"/resume-quiz-attempt",
json={"teacher_token": TEACHER_TOKEN, "student_id": _student_id()},
)
assert resp.status_code == 400
assert resp.get_json()["success"] is False
def test_resume_unknown_student_fails(client):
resp = _post_resume(client, str(uuid.uuid4()))
assert resp.status_code == 200
assert resp.get_json()["success"] is False
def test_resume_unknown_lesson_fails(client):
resp = _post_resume(client, _student_id(), lesson="lesson_tidak_ada")
assert resp.status_code == 200
assert resp.get_json()["success"] is False
# ── syarat attempt ──────────────────────────────────────────────
def test_resume_without_attempt_fails(client):
resp = _post_resume(client, _student_id())
assert resp.status_code == 200
assert resp.get_json()["success"] is False
def test_resume_submitted_attempt_rejected(client):
"""Attempt submitted (selesai normal) tidak perlu & tidak bisa di-resume."""
attempt_id = str(uuid.uuid4())
assert _post_quiz(client, attempt_id, status="submitted", reason=None, score="4/4").status_code == 200
resp = _post_resume(client, _student_id())
assert resp.status_code == 200
assert resp.get_json()["success"] is False
# ── perilaku sukses ─────────────────────────────────────────────
def test_resume_terminated_attempt_succeeds(client):
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
resp = _post_resume(client, _student_id())
assert resp.status_code == 200
assert resp.get_json()["success"] is True
# resumed_at terisi di DB
from services import repositories as repo
from services.database import SessionLocal
db = SessionLocal()
try:
user = repo.find_user_by_raw_token(db, STUDENT_TOKEN)
lesson = repo.get_lesson_by_slug(db, LESSON)
attempt = repo.get_quiz_attempt_for_user_lesson(db, user_id=user.id, lesson_id=lesson.id)
assert attempt is not None
assert attempt.resumed_at is not None
finally:
db.close()
# gate one-attempt terbuka
from services.token_service import has_quiz_attempt
assert has_quiz_attempt(STUDENT_TOKEN, LESSON) is False
def test_resume_idempotent(client):
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="page_unload", score="1/4"
).status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
# Resume ulang tetap sukses (tanpa batas, keputusan desain 3a)
assert _post_resume(client, _student_id()).get_json()["success"] is True
# ── roundtrip penuh: resume → selesaikan ulang → idempotent ────
def test_resume_then_resubmit_updates_attempt_and_progress(client):
"""Siswa menyelesaikan ulang kuis dengan attempt_id SAMA setelah resume.
Row attempt ditimpa sekali (status submitted, skor baru), resumed_at
di-clear, dan progress quiz di-update. Retry payload sama → idempotent.
"""
attempt_id = str(uuid.uuid4())
answers_v1 = [
{"question_id": "q1", "selected_option_id": "o-a", "is_correct": True, "category": "evaluasi", "type": "mcq"},
{"question_id": "q2", "selected_option_id": None, "is_correct": False, "category": "evaluasi", "type": "mcq"},
]
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4", answers=answers_v1
).status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
# Siswa menyelesaikan ulang: semua soal benar
answers_v2 = [
{"question_id": "q1", "selected_option_id": "o-a", "is_correct": True, "category": "evaluasi", "type": "mcq"},
{"question_id": "q2", "selected_option_id": "o-b", "is_correct": True, "category": "evaluasi", "type": "mcq"},
]
resp = _post_quiz(
client, attempt_id, status="submitted", reason=None, score="4/4", answers=answers_v2
)
assert resp.status_code == 200
body = resp.get_json()
assert body["success"] is True
assert body["idempotent"] is False
from services import repositories as repo
from services.database import SessionLocal
db = SessionLocal()
try:
user = repo.find_user_by_raw_token(db, STUDENT_TOKEN)
lesson = repo.get_lesson_by_slug(db, LESSON)
attempt = repo.get_quiz_attempt_for_user_lesson(db, user_id=user.id, lesson_id=lesson.id)
assert attempt.status == "submitted"
assert attempt.termination_reason is None
assert attempt.score_earned == 4
assert attempt.score_total == 4
assert attempt.resumed_at is None, "resumed_at harus ter-clear setelah finalisasi ulang"
progress = repo.get_progress(db, user_id=user.id, lesson_id=lesson.id)
assert progress.quiz_score_earned == 4
assert progress.quiz_score_total == 4
finally:
db.close()
# Retry payload sama → idempotent, row tidak ditimpa lagi
retry = _post_quiz(
client, attempt_id, status="submitted", reason=None, score="4/4", answers=answers_v2
)
assert retry.get_json()["idempotent"] is True
def test_resume_then_terminate_again_allows_second_resume(client):
"""Siswa fokus-lost lagi saat sesi lanjutan → terminated lagi → resume ulang."""
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
# Sesi lanjutan dihentikan lagi (fokus-lost kedua)
again = _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="3/4"
)
assert again.status_code == 200
assert again.get_json()["idempotent"] is False
from services import repositories as repo
from services.database import SessionLocal
db = SessionLocal()
try:
user = repo.find_user_by_raw_token(db, STUDENT_TOKEN)
lesson = repo.get_lesson_by_slug(db, LESSON)
attempt = repo.get_quiz_attempt_for_user_lesson(db, user_id=user.id, lesson_id=lesson.id)
assert attempt.status == "terminated"
assert attempt.score_earned == 3
assert attempt.resumed_at is None
finally:
db.close()
# Guru boleh resume ulang (tanpa batas)
assert _post_resume(client, _student_id()).get_json()["success"] is True
# ── report guru ─────────────────────────────────────────────────
def test_report_includes_resumed_at(client):
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
# Sebelum resume: kosong
client.set_cookie("student_token", TEACHER_TOKEN)
report = client.get("/progress-report.json")
budi = next(s for s in report.get_json()["students"] if s["nama_siswa"] == "Budi Santoso")
assert budi[f"{LESSON}_resumed_at"] == ""
# Setelah resume: terisi ISO timestamp
assert _post_resume(client, _student_id()).get_json()["success"] is True
report = client.get("/progress-report.json")
budi = next(s for s in report.get_json()["students"] if s["nama_siswa"] == "Budi Santoso")
assert budi[f"{LESSON}_resumed_at"] != ""
# ── sinyal UI siswa & gate setelah re-finalisasi ────────────────
def test_lesson_data_quiz_resumable_flag(client):
"""Endpoint data lesson membawa `quiz_resumable` yang dipakai UI siswa."""
import pathlib
from services import lesson_service
content_dir = pathlib.Path(os.environ["CONTENT_DIR"])
(content_dir / f"{LESSON}.md").write_text("# Hello World\n\nMateri.\n", encoding="utf-8")
for fn in (
lesson_service.find_lesson_file,
lesson_service.get_lessons,
lesson_service.get_lesson_names,
lesson_service.get_lessons_with_learning_objectives,
):
fn.cache_clear()
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
# Sebelum resume: gate aktif, belum resumable
resp = client.get(f"/lesson/{LESSON}.json?token={STUDENT_TOKEN}")
assert resp.status_code == 200
data = resp.get_json()
assert data["has_quiz_attempt"] is True
assert data["quiz_resumable"] is False
assert _post_resume(client, _student_id()).get_json()["success"] is True
# Setelah resume: gate terbuka + sinyal resume
resp = client.get(f"/lesson/{LESSON}.json?token={STUDENT_TOKEN}")
data = resp.get_json()
assert data["has_quiz_attempt"] is False
assert data["quiz_resumable"] is True
def test_has_quiz_attempt_closed_after_resubmit(client):
"""Setelah siswa menyelesaikan ulang, gate one-attempt aktif kembali."""
from services.token_service import has_quiz_attempt
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
assert has_quiz_attempt(STUDENT_TOKEN, LESSON) is False
assert _post_quiz(
client, attempt_id, status="submitted", reason=None, score="4/4"
).status_code == 200
assert has_quiz_attempt(STUDENT_TOKEN, LESSON) is True
def test_reset_while_resumed_still_works(client):
"""Guru me-reset attempt yang sedang di-resume — attempt dihapus, progress bersih."""
from services.token_service import has_quiz_attempt
attempt_id = str(uuid.uuid4())
assert _post_quiz(
client, attempt_id, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
resp = client.post(
"/reset-progress",
json={
"teacher_token": TEACHER_TOKEN,
"student_id": _student_id(),
"lesson_name": LESSON,
},
)
assert resp.status_code == 200
assert resp.get_json()["success"] is True
# Attempt hilang + progress not_started
assert has_quiz_attempt(STUDENT_TOKEN, LESSON) is False
get_resp = client.get(f"/lesson-progress/{LESSON}?token={STUDENT_TOKEN}")
data = get_resp.get_json()
assert data["attempt_id"] is None
assert data["state"] == "not_started"
def test_resubmit_different_attempt_id_while_resumed_conflicts(client):
"""Attempt_id BARU saat (user, lesson) masih punya attempt → 409 one-attempt."""
first = str(uuid.uuid4())
assert _post_quiz(
client, first, status="terminated", reason="focus_lost", score="2/4"
).status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
second = str(uuid.uuid4())
resp = _post_quiz(client, second, status="submitted", reason=None, score="4/4")
assert resp.status_code == 409
def test_resume_attempt_without_answers(client):
"""Attempt legacy tanpa answers_json → resume tetap jalan (answers kosong)."""
attempt_id = str(uuid.uuid4())
# Payload TANPA field "answers" → answers_json NULL
resp = client.post(
"/lesson-progress",
json={
"token": STUDENT_TOKEN,
"lesson_name": LESSON,
"type": "quiz",
"attempt_id": attempt_id,
"status": "terminated",
"termination_reason": "page_unload",
"score": "1/4",
"occurred_at": "2026-08-09T14:04:44.000Z",
"started_at": "2026-08-09T14:03:00.000Z",
"visibility_event_count": 1,
},
)
assert resp.status_code == 200
assert _post_resume(client, _student_id()).get_json()["success"] is True
get_resp = client.get(f"/lesson-progress/{LESSON}?token={STUDENT_TOKEN}")
data = get_resp.get_json()
assert data["answers"] == []
assert data["attempt_resumed_at"] is not None