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