Quiiz system
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user