# retoor import json from datetime import datetime, timedelta, timezone import pytest from devplacepy.database import get_table from devplacepy.services.quiz import store from devplacepy.services.quiz.store import QuizError from devplacepy.utils import generate_uid, make_combined_slug from tests.conftest import run_async _counter = [0] def _user(prefix): _counter[0] += 1 uid = generate_uid() get_table("users").insert( { "uid": uid, "username": f"{prefix}{_counter[0]}", "email": f"{prefix}{_counter[0]}@t.dev", "role": "Member", "api_key": generate_uid(), "created_at": datetime.now(timezone.utc).isoformat(), } ) return get_table("users").find_one(uid=uid) def _quiz(owner, **overrides): uid = generate_uid() fields = { "uid": uid, "user_uid": owner["uid"], "slug": make_combined_slug("Quiz", uid), "title": "Quiz", "description": "d", "status": "draft", "published_at": "", "shuffle_questions": 0, "shuffle_options": 0, "reveal_answers": 1, "allow_review": 1, "time_limit_seconds": 0, "pass_percent": 0, "question_count": 0, "total_points": 0, "attempt_count": 0, "stars": 0, } fields.update(overrides) get_table("quizzes").insert(store.born_live(fields)) return store.get_quiz(uid) def _numeric(quiz_uid, value=1.0, points=1): return store.add_question( quiz_uid, { "kind": "numeric", "prompt": f"Value {value}", "points": points, "numeric_value": value, "numeric_tolerance": 0.0, "options": [], }, ) def _choice(quiz_uid, points=2): return store.add_question( quiz_uid, { "kind": "single_choice", "prompt": "Pick one", "points": points, "options": [ {"label": "wrong", "match_value": "", "is_correct": False}, {"label": "right", "match_value": "", "is_correct": True}, ], }, ) def test_recompute_quiz_totals_reflects_the_live_questions(local_db): owner = _user("qs") quiz = _quiz(owner) _numeric(quiz["uid"], points=3) _choice(quiz["uid"], points=2) refreshed = store.get_quiz(quiz["uid"]) assert refreshed["question_count"] == 2 assert refreshed["total_points"] == 5 def test_deleting_a_question_recomputes_and_renumbers(local_db): owner = _user("qs") quiz = _quiz(owner) first = _numeric(quiz["uid"], points=3) _numeric(quiz["uid"], value=2.0, points=4) store.delete_question(quiz["uid"], first["uid"], owner["uid"]) refreshed = store.get_quiz(quiz["uid"]) assert refreshed["question_count"] == 1 assert refreshed["total_points"] == 4 assert store.list_questions(quiz["uid"])[0]["position"] == 0 def test_reorder_requires_every_question_exactly_once(local_db): owner = _user("qs") quiz = _quiz(owner) first = _numeric(quiz["uid"]) _numeric(quiz["uid"], value=2.0) with pytest.raises(QuizError): store.reorder_questions(quiz["uid"], [first["uid"]]) def test_reorder_applies_the_new_order(local_db): owner = _user("qs") quiz = _quiz(owner) first = _numeric(quiz["uid"]) second = _numeric(quiz["uid"], value=2.0) store.reorder_questions(quiz["uid"], [second["uid"], first["uid"]]) assert [q["uid"] for q in store.list_questions(quiz["uid"])] == [second["uid"], first["uid"]] def test_validation_errors_flags_an_empty_quiz(local_db): owner = _user("qs") quiz = _quiz(owner) assert store.validation_errors(quiz["uid"]) def test_validation_errors_is_empty_for_a_complete_quiz(local_db): owner = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) assert store.validation_errors(quiz["uid"]) == [] def test_validation_errors_flags_a_free_text_without_criteria(local_db): owner = _user("qs") quiz = _quiz(owner) store.add_question( quiz["uid"], {"kind": "free_text", "prompt": "Explain", "points": 1, "options": []}, ) assert any("reference answer" in problem for problem in store.validation_errors(quiz["uid"])) def test_validation_errors_flags_a_matching_with_one_pair(local_db): owner = _user("qs") quiz = _quiz(owner) store.add_question( quiz["uid"], { "kind": "matching", "prompt": "Match", "points": 1, "options": [{"label": "a", "match_value": "b", "is_correct": False}], }, ) assert any("two pairs" in problem for problem in store.validation_errors(quiz["uid"])) def test_publish_refuses_an_incomplete_quiz(local_db): owner = _user("qs") quiz = _quiz(owner) with pytest.raises(QuizError): store.publish_quiz(quiz["uid"]) assert store.get_quiz(quiz["uid"])["status"] == "draft" def test_publish_sets_the_status_once(local_db): owner = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) published = store.publish_quiz(quiz["uid"]) assert published["status"] == "published" assert published["published_at"] assert published["publish_won"] is True def test_publishing_twice_does_not_win_twice(local_db): owner = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) again = store.publish_quiz(quiz["uid"]) assert again.get("publish_won") is not True def test_guard_editable_refuses_a_published_quiz(local_db): owner = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) with pytest.raises(QuizError): store.guard_editable(quiz["uid"]) def test_every_mutating_entrypoint_is_frozen_after_publish(local_db): owner = _user("qs") quiz = _quiz(owner) question = _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) with pytest.raises(QuizError): store.add_question(quiz["uid"], {"kind": "numeric", "prompt": "x", "options": []}) with pytest.raises(QuizError): store.edit_question(quiz["uid"], question["uid"], {"kind": "numeric", "prompt": "x", "options": []}) with pytest.raises(QuizError): store.delete_question(quiz["uid"], question["uid"], owner["uid"]) with pytest.raises(QuizError): store.reorder_questions(quiz["uid"], [question["uid"]]) with pytest.raises(QuizError): store.edit_quiz(quiz["uid"], {"title": "new"}) def test_a_published_quiz_keeps_its_question_bytes(local_db): owner = _user("qs") quiz = _quiz(owner) question = _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) with pytest.raises(QuizError): store.edit_question( quiz["uid"], question["uid"], {"kind": "numeric", "prompt": "changed", "options": []} ) assert store.list_questions(quiz["uid"])[0]["prompt"] == "Pick one" def test_can_view_quiz_hides_a_draft_from_everyone_else(local_db): owner = _user("qs") other = _user("qs") quiz = _quiz(owner) assert store.can_view_quiz(quiz, owner) is True assert store.can_view_quiz(quiz, other) is False assert store.can_view_quiz(quiz, None) is False def test_can_view_quiz_allows_everyone_once_published(local_db): owner = _user("qs") other = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) assert store.can_view_quiz(store.get_quiz(quiz["uid"]), other) is True assert store.can_view_quiz(store.get_quiz(quiz["uid"]), None) is True def test_starting_twice_returns_the_same_attempt(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) first = store.start_attempt(player, quiz) second = store.start_attempt(player, quiz) assert first["uid"] == second["uid"] live = get_table("quiz_attempts").count( user_uid=player["uid"], quiz_uid=quiz["uid"], status="in_progress", deleted_at=None ) assert live == 1 def test_starting_materializes_a_blank_answer_per_question(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) _numeric(quiz["uid"]) store.publish_quiz(quiz["uid"]) attempt = store.start_attempt(player, store.get_quiz(quiz["uid"])) answers = store.answers_for(attempt["uid"]) assert len(answers) == 2 assert all(not answer["answered_at"] for answer in answers) def test_starting_snapshots_the_max_points(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"], points=5) store.publish_quiz(quiz["uid"]) attempt = store.start_attempt(player, store.get_quiz(quiz["uid"])) assert attempt["max_points"] == 5 def test_answering_credits_the_attempt(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) question = _numeric(quiz["uid"], value=7.0, points=4) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) answer, updated, result = run_async( store.answer( player, quiz, attempt, question["uid"], {"answer_text": "7", "option_uids": []} ) ) assert answer["is_correct"] == 1 assert float(answer["awarded_points"]) == 4.0 assert float(updated["score_points"]) == 4.0 assert int(updated["answered_count"]) == 1 assert result.graded_by == "auto" def test_a_second_answer_is_refused_and_credits_nothing(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) question = _numeric(quiz["uid"], value=7.0, points=4) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) run_async( store.answer( player, quiz, attempt, question["uid"], {"answer_text": "7", "option_uids": []} ) ) with pytest.raises(QuizError): run_async( store.answer( player, quiz, store.get_attempt(attempt["uid"]), question["uid"], {"answer_text": "7", "option_uids": []}, ) ) refreshed = store.get_attempt(attempt["uid"]) assert float(refreshed["score_points"]) == 4.0 assert int(refreshed["answered_count"]) == 1 def test_finishing_recomputes_the_score_from_the_answers(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, pass_percent=50) first = _numeric(quiz["uid"], value=1.0, points=2) _numeric(quiz["uid"], value=2.0, points=2) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) run_async( store.answer( player, quiz, attempt, first["uid"], {"answer_text": "1", "option_uids": []} ) ) finished, won = store.finish(quiz, store.get_attempt(attempt["uid"])) assert won is True assert finished["status"] == "completed" assert float(finished["score_points"]) == 2.0 assert float(finished["score_percent"]) == 50.0 assert int(finished["passed"]) == 1 def test_finishing_twice_wins_only_once(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) store.finish(quiz, attempt) _, won = store.finish(quiz, store.get_attempt(attempt["uid"])) assert won is False assert int(store.get_quiz(quiz["uid"])["attempt_count"]) == 1 def test_an_expired_attempt_refuses_further_answers(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, time_limit_seconds=60) question = _numeric(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat() get_table("quiz_attempts").update({"uid": attempt["uid"], "expires_at": past}, ["uid"]) with pytest.raises(QuizError): run_async( store.answer( player, quiz, store.get_attempt(attempt["uid"]), question["uid"], {"answer_text": "1", "option_uids": []}, ) ) assert store.get_attempt(attempt["uid"])["status"] == "expired" def test_expiry_happens_lazily_on_a_read(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, time_limit_seconds=60) _numeric(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat() get_table("quiz_attempts").update({"uid": attempt["uid"], "expires_at": past}, ["uid"]) serialized = store.serialize_attempt(quiz, store.get_attempt(attempt["uid"]), player) assert serialized["status"] == "expired" def test_the_serializer_withholds_the_answer_key_from_a_player(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, reveal_answers=0) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) payload = store.serialize_attempt(quiz, attempt, player) question = payload["questions"][0] assert "correct_boolean" not in question assert "expected_answer" not in question assert all("is_correct" not in option for option in question["options"]) def test_the_serializer_gives_the_owner_the_answer_key(local_db): owner = _user("qs") quiz = _quiz(owner, reveal_answers=0) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(owner, quiz) payload = store.serialize_attempt(quiz, attempt, owner) question = payload["questions"][0] assert any("is_correct" in option for option in question["options"]) def test_the_serializer_reveals_an_answered_question_when_configured(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, reveal_answers=1) question = _numeric(quiz["uid"], value=3.0) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) run_async( store.answer( player, quiz, attempt, question["uid"], {"answer_text": "3", "option_uids": []} ) ) payload = store.serialize_attempt(quiz, store.get_attempt(attempt["uid"]), player) assert "numeric_value" in payload["questions"][0] def test_matching_choices_are_exposed_without_the_pairing(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, reveal_answers=0) store.add_question( quiz["uid"], { "kind": "matching", "prompt": "Match", "points": 1, "options": [ {"label": "a", "match_value": "A", "is_correct": False}, {"label": "b", "match_value": "B", "is_correct": False}, ], }, ) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) attempt = store.start_attempt(player, quiz) question = store.serialize_attempt(quiz, attempt, player)["questions"][0] assert sorted(question["match_choices"]) == ["A", "B"] assert all("match_value" not in option for option in question["options"]) def test_the_cascade_removes_every_child_under_one_stamp(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) store.start_attempt(player, store.get_quiz(quiz["uid"])) stamp = datetime.now(timezone.utc).isoformat() store.cascade_questions(quiz["uid"], owner["uid"], stamp) for table in ("quiz_questions", "quiz_options", "quiz_attempts", "quiz_answers"): live = get_table(table).count(quiz_uid=quiz["uid"], deleted_at=None) stamped = get_table(table).count(quiz_uid=quiz["uid"], deleted_at=stamp) assert live == 0 assert stamped > 0 def test_prune_attempts_keeps_completed_records(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) quiz = store.get_quiz(quiz["uid"]) kept = store.start_attempt(player, quiz) store.finish(quiz, kept) future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() store.prune_attempts(future) assert store.get_attempt(kept["uid"]) is not None def test_prune_attempts_removes_an_abandoned_one(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner) _choice(quiz["uid"]) store.publish_quiz(quiz["uid"]) attempt = store.start_attempt(player, store.get_quiz(quiz["uid"])) future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat() store.prune_attempts(future) assert store.get_attempt(attempt["uid"]) is None def test_the_attempt_order_is_persisted_as_json(local_db): owner = _user("qs") player = _user("qs") quiz = _quiz(owner, shuffle_questions=1) _numeric(quiz["uid"], value=1.0) _numeric(quiz["uid"], value=2.0) _numeric(quiz["uid"], value=3.0) store.publish_quiz(quiz["uid"]) attempt = store.start_attempt(player, store.get_quiz(quiz["uid"])) order = json.loads(attempt["question_order"]) assert sorted(order) == sorted(store.question_uids(quiz["uid"])) assert store.attempt_order(store.get_attempt(attempt["uid"])) == order