2026-07-26 14:57:18 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
|
|
|
|
from devplacepy.config import QUIZ_MAX_OPTIONS, QUIZ_MAX_QUESTIONS
|
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
|
from devplacepy.models import QuizDocument, QuizImportForm
|
|
|
|
|
from devplacepy.services.quiz import store
|
|
|
|
|
from devplacepy.services.quiz.store import QuizError
|
|
|
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
|
|
|
|
|
|
_counter = [0]
|
|
|
|
|
|
|
|
|
|
DOCUMENT = {
|
|
|
|
|
"title": "SQLite fundamentals",
|
|
|
|
|
"description": "Questions on WAL and indexing.",
|
|
|
|
|
"settings": {"shuffle_questions": True, "reveal_answers": True, "pass_percent": 70},
|
|
|
|
|
"questions": [
|
|
|
|
|
{
|
|
|
|
|
"kind": "single_choice",
|
|
|
|
|
"prompt": "Which journal mode allows concurrent readers?",
|
|
|
|
|
"points": 2,
|
|
|
|
|
"explanation": "WAL keeps readers off the writer's lock.",
|
|
|
|
|
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"kind": "free_text",
|
|
|
|
|
"prompt": "Why does a partial index help?",
|
|
|
|
|
"points": 3,
|
|
|
|
|
"expected_answer": "It keeps the live query off a one bucket index.",
|
|
|
|
|
"grading_criteria": "Accept any answer mentioning the planner.",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _user(prefix):
|
|
|
|
|
_counter[0] += 1
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
get_table("users").insert(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"username": f"{prefix}{_counter[0]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-07-26 14:57:18 +02:00
|
|
|
"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, document):
|
|
|
|
|
uid = generate_uid()
|
|
|
|
|
get_table("quizzes").insert(
|
|
|
|
|
store.born_live(
|
|
|
|
|
{
|
|
|
|
|
"uid": uid,
|
|
|
|
|
"user_uid": owner["uid"],
|
|
|
|
|
"slug": make_combined_slug(document.title, uid),
|
|
|
|
|
"title": document.title,
|
|
|
|
|
"description": document.description,
|
|
|
|
|
"status": "draft",
|
|
|
|
|
"published_at": "",
|
|
|
|
|
"question_count": 0,
|
|
|
|
|
"total_points": 0,
|
|
|
|
|
"attempt_count": 0,
|
|
|
|
|
"stars": 0,
|
|
|
|
|
**store.document_settings(document),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
store.import_questions(uid, document)
|
|
|
|
|
return store.get_quiz(uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_document_accepts_the_reference_shape():
|
|
|
|
|
document = store.parse_document(DOCUMENT)
|
|
|
|
|
assert document.title == "SQLite fundamentals"
|
|
|
|
|
assert len(document.questions) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_document_rejects_junk():
|
|
|
|
|
with pytest.raises(QuizError):
|
|
|
|
|
store.parse_document({"title": "x"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_import_form_parses_a_json_string():
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
form = QuizImportForm(document=json.dumps(DOCUMENT))
|
|
|
|
|
assert form.document.title == "SQLite fundamentals"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_import_form_rejects_invalid_json():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizImportForm(document="{not json")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_document_needs_at_least_one_question():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizDocument(title="Empty quiz", questions=[])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_document_is_capped_at_the_question_limit():
|
|
|
|
|
question = {"kind": "numeric", "prompt": "q", "numeric_value": 1}
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizDocument(title="Too big", questions=[question] * (QUIZ_MAX_QUESTIONS + 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_question_is_capped_at_the_option_limit():
|
|
|
|
|
options = [{"label": f"L{index}"} for index in range(QUIZ_MAX_OPTIONS + 1)]
|
|
|
|
|
options[0]["is_correct"] = True
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizDocument(
|
|
|
|
|
title="Too many options",
|
|
|
|
|
questions=[{"kind": "single_choice", "prompt": "q", "options": options}],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_single_choice_needs_exactly_one_correct_option():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizDocument(
|
|
|
|
|
title="Bad choice",
|
|
|
|
|
questions=[
|
|
|
|
|
{
|
|
|
|
|
"kind": "single_choice",
|
|
|
|
|
"prompt": "q",
|
|
|
|
|
"options": [{"label": "a"}, {"label": "b"}],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_free_text_needs_a_reference_or_criteria():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizDocument(
|
|
|
|
|
title="Bad free text",
|
|
|
|
|
questions=[{"kind": "free_text", "prompt": "explain"}],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_matching_needs_a_right_hand_value_on_every_pair():
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
QuizDocument(
|
|
|
|
|
title="Bad matching",
|
|
|
|
|
questions=[
|
|
|
|
|
{
|
|
|
|
|
"kind": "matching",
|
|
|
|
|
"prompt": "q",
|
|
|
|
|
"options": [{"label": "a", "match_value": "A"}, {"label": "b"}],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_document_settings_map_onto_the_quiz_columns():
|
|
|
|
|
settings = store.document_settings(store.parse_document(DOCUMENT))
|
|
|
|
|
assert settings["shuffle_questions"] == 1
|
|
|
|
|
assert settings["reveal_answers"] == 1
|
|
|
|
|
assert settings["pass_percent"] == 70
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_importing_creates_every_question_in_order(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
questions = store.list_questions(quiz["uid"])
|
|
|
|
|
assert [q["kind"] for q in questions] == ["single_choice", "free_text"]
|
|
|
|
|
assert [q["position"] for q in questions] == [0, 1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_importing_recomputes_the_totals(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
assert quiz["question_count"] == 2
|
|
|
|
|
assert quiz["total_points"] == 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_importing_stores_the_kind_specific_fields(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
free_text = store.list_questions(quiz["uid"])[1]
|
|
|
|
|
assert free_text["expected_answer"].startswith("It keeps")
|
|
|
|
|
assert free_text["grading_criteria"].startswith("Accept")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_owner_export_round_trips_through_import(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
exported = store.export_document(quiz["uid"], True)
|
|
|
|
|
reimported = _quiz(owner, store.parse_document(exported))
|
|
|
|
|
assert reimported["question_count"] == quiz["question_count"]
|
|
|
|
|
assert reimported["total_points"] == quiz["total_points"]
|
|
|
|
|
assert store.export_document(reimported["uid"], True) == exported
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_owner_export_carries_the_answer_key(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
exported = store.export_document(quiz["uid"], True)
|
|
|
|
|
assert any(option.get("is_correct") for option in exported["questions"][0]["options"])
|
|
|
|
|
assert exported["questions"][1]["expected_answer"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_public_export_omits_the_answer_key(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
exported = store.export_document(quiz["uid"], False)
|
|
|
|
|
assert all("is_correct" not in option for option in exported["questions"][0]["options"])
|
|
|
|
|
assert "expected_answer" not in exported["questions"][1]
|
|
|
|
|
assert "correct_boolean" not in exported["questions"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_public_export_keeps_the_prompts_and_labels(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
exported = store.export_document(quiz["uid"], False)
|
|
|
|
|
assert exported["questions"][0]["prompt"].startswith("Which journal mode")
|
|
|
|
|
assert [option["label"] for option in exported["questions"][0]["options"]] == ["DELETE", "WAL"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_export_carries_the_settings(local_db):
|
|
|
|
|
owner = _user("qd")
|
|
|
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
|
|
|
exported = store.export_document(quiz["uid"], False)
|
|
|
|
|
assert exported["settings"]["pass_percent"] == 70
|
|
|
|
|
assert exported["settings"]["reveal_answers"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_exporting_an_unknown_quiz_raises(local_db):
|
|
|
|
|
with pytest.raises(QuizError):
|
|
|
|
|
store.export_document("does-not-exist", True)
|