395 lines
15 KiB
Python
Raw Normal View History

2026-07-26 14:57:18 +02:00
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
import requests
from tests.api.quizzes._helpers import (
JSON,
NUMERIC,
answer_correctly,
attempt_state,
build_published,
create_quiz,
signup,
start_attempt,
)
from tests.conftest import BASE_URL
def _play(session, slug, attempt_uid):
state = attempt_state(session, slug, attempt_uid)
for question in state["attempt"]["questions"]:
answer_correctly(session, slug, attempt_uid, question)
return session.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON
)
def test_starting_requires_auth(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Auth attempt quiz")
response = requests.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
assert response.status_code in (401, 303)
def test_a_draft_cannot_be_played(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Unplayable draft")
response = session.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
assert response.status_code == 400
def test_starting_returns_an_in_progress_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Start quiz")
player, _ = signup()
response = player.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
assert response.status_code == 200
assert response.json()["data"]["status"] == "in_progress"
def test_starting_twice_resumes_the_same_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Resume quiz")
player, _ = signup()
first = start_attempt(player, slug)
second = start_attempt(player, slug)
assert first == second
def test_the_attempt_page_lists_every_question(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Question list quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = attempt_state(player, slug, attempt_uid)
assert payload["attempt"]["question_count"] == 2
assert len(payload["attempt"]["questions"]) == 2
def test_the_attempt_snapshots_the_max_points(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Max points quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
assert attempt_state(player, slug, attempt_uid)["attempt"]["max_points"] == 3
def test_another_member_cannot_read_the_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Private attempt quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
other, _ = signup()
response = other.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON)
assert response.status_code == 404
def test_the_quiz_author_cannot_read_a_members_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Author blind quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
response = owner.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON)
assert response.status_code == 404
def test_answering_grades_and_credits(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Grading quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
payload = answer_correctly(player, slug, attempt_uid, question).json()
assert payload["answer"]["answered"] is True
assert payload["answer"]["is_correct"] is True
assert payload["answer"]["graded_by"] == "auto"
assert payload["attempt"]["answered_count"] == 1
def test_a_wrong_answer_scores_zero(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Wrong answer quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
payload = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "7"},
headers=JSON,
).json()
assert payload["answer"]["is_correct"] is False
assert payload["answer"]["awarded_points"] == 0.0
def test_a_double_submit_is_refused(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Double submit quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
answer_correctly(player, slug, attempt_uid, question)
second = answer_correctly(player, slug, attempt_uid, question)
assert second.status_code == 400
assert "already answered" in second.json()["error"]["message"]
def test_a_double_submit_credits_nothing(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="No double credit quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
first = answer_correctly(player, slug, attempt_uid, question).json()
answer_correctly(player, slug, attempt_uid, question)
after = attempt_state(player, slug, attempt_uid)["attempt"]
assert after["answered_count"] == first["attempt"]["answered_count"]
assert after["score_points"] == first["attempt"]["score_points"]
def test_answering_an_unknown_question_is_a_400(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Unknown question quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
response = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": "nope", "answer_text": "x"},
headers=JSON,
)
assert response.status_code == 400
def test_an_oversized_answer_is_rejected_before_grading(app_server, seeded_db):
from devplacepy.config import QUIZ_ANSWER_MAX_CHARS
owner, _ = signup()
slug = build_published(owner, title="Oversized answer quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
response = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "x" * (QUIZ_ANSWER_MAX_CHARS + 1)},
headers=JSON,
)
assert response.status_code == 422
def test_finishing_returns_the_result(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Finish quiz", pass_percent="50")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = _play(player, slug, attempt_uid).json()
assert payload["attempt"]["status"] == "completed"
assert payload["attempt"]["score_percent"] == 100.0
assert payload["attempt"]["passed"] is True
def test_finishing_twice_returns_the_same_result(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Finish twice quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
first = _play(player, slug, attempt_uid).json()
second = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON
).json()
assert second["attempt"]["score_points"] == first["attempt"]["score_points"]
assert second["attempt"]["status"] == "completed"
def test_finishing_twice_increments_the_attempt_count_once(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Attempt count quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
payload = requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["quiz"]["attempt_count"] == 1
def test_finishing_notifies_the_author_once(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, owner_name = signup()
slug = build_published(owner, title="Notify quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
refresh_snapshot()
author = get_table("users").find_one(username=owner_name)
count = get_table("notifications").count(user_uid=author["uid"], type="quiz_attempt")
assert count == 1
def test_playing_again_creates_a_second_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Replay quiz")
player, _ = signup()
first = start_attempt(player, slug)
_play(player, slug, first)
second = start_attempt(player, slug)
assert second != first
def test_an_expired_attempt_refuses_answers(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, _ = signup()
slug = build_published(owner, title="Expiring quiz", time_limit_seconds="3600")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
refresh_snapshot()
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
get_table("quiz_attempts").update({"uid": attempt_uid, "expires_at": past}, ["uid"])
refresh_snapshot()
response = answer_correctly(player, slug, attempt_uid, question)
assert response.status_code == 400
def test_an_expired_attempt_reads_as_expired_without_a_background_pass(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, _ = signup()
slug = build_published(owner, title="Lazy expiry quiz", time_limit_seconds="3600")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
refresh_snapshot()
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
get_table("quiz_attempts").update({"uid": attempt_uid, "expires_at": past}, ["uid"])
refresh_snapshot()
payload = attempt_state(player, slug, attempt_uid)
assert payload["attempt"]["status"] == "expired"
def test_a_time_limited_attempt_reports_remaining_seconds(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Timer quiz", time_limit_seconds="600")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = attempt_state(player, slug, attempt_uid)
assert 0 < payload["attempt"]["remaining_seconds"] <= 600
def test_the_results_page_is_readable_by_the_attempt_owner(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Results quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
response = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
)
assert response.status_code == 200
assert response.json()["attempt"]["status"] == "completed"
def test_the_results_page_is_hidden_from_another_member(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Private results quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
other, _ = signup()
response = other.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
)
assert response.status_code == 404
def test_an_admin_can_read_the_results(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Admin results quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
admin = requests.Session()
admin.post(
f"{BASE_URL}/auth/login",
data={"email": seeded_db["alice"]["email"], "password": seeded_db["alice"]["password"]},
allow_redirects=True,
)
response = admin.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
)
assert response.status_code == 200
def test_the_review_is_omitted_when_the_author_disabled_it(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="No review quiz", allow_review="0")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
payload = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
).json()
assert payload["review"] == []
def test_the_review_is_present_when_allowed(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Review quiz", allow_review="1")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
payload = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
).json()
assert len(payload["review"]) == 2
def test_the_answer_key_is_withheld_mid_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Withheld key quiz", reveal_answers="0")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = attempt_state(player, slug, attempt_uid)
for question in payload["attempt"]["questions"]:
assert question["correct_boolean"] is None
assert question["expected_answer"] is None
assert question["numeric_value"] is None
for option in question["options"]:
assert option["is_correct"] is None
assert option["match_value"] is None
def test_the_answer_key_reaches_the_quiz_owner(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Owner key quiz", reveal_answers="0")
attempt_uid = start_attempt(owner, slug)
payload = attempt_state(owner, slug, attempt_uid)
choice = next(
question
for question in payload["attempt"]["questions"]
if question["kind"] == "single_choice"
)
assert any(option["is_correct"] for option in choice["options"])
def test_an_answered_question_reveals_its_key_when_configured(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Reveal quiz", reveal_answers="1")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
answer_correctly(player, slug, attempt_uid, question)
after = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
assert after["numeric_value"] == 1024.0
def test_answering_a_deleted_quiz_still_uses_the_attempt_snapshot(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Deleted mid attempt quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
owner.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON)
response = player.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON)
assert response.status_code == 404