|
# 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
|