pdf-quiz-generator/backend/tests/test_question_folders.py
Daniel 532d613393 feat: question folders, per-section notes, and two feedback paths
Four things that share a spine, so they arrive together.

**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.

Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.

**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.

**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.

The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:37:43 +02:00

205 lines
11 KiB
Python

"""Question folders: the one grantable thing a branch, an exam and a tag cannot name.
Disposable SQLite. The rules worth pinning are the ones that decide whether a
folder is access or a list: a folder confers nothing until a moderator grants
it, a grant over one reaches exactly its contents, nobody can widen their own
reach by filling a folder, and deleting a folder takes the grants with it.
"""
import unittest
import test_quiz_builder as fixtures
from app.models.category_grant import CategoryGrant
from app.models.folder import QuestionFolder, QuestionFolderQuestion
from app.models.question import Question
from app.routers import access, folders
from app.utils.category_grants import question_scope_predicate
class FolderTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(folders.router, prefix="/folders")
self.client.app.include_router(access.router, prefix="/access")
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def make(self, name="Week 3 assignment"):
response = self.client.post("/folders/", json={"name": name})
self.assertEqual(response.status_code, 201, response.text)
return response.json()["id"]
def reaches(self, user):
predicate = question_scope_predicate(self.db, user)
if predicate is None:
return {row[0] for row in self.db.query(Question.id).all()}
return {row[0] for row in self.db.query(Question.id).filter(predicate).all()}
# ── the folder itself ─────────────────────────────────────────────────────
def test_an_educator_builds_a_folder_and_fills_it(self):
folder_id = self.make()
added = self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1, 3, 3]})
self.assertEqual(added.status_code, 200, added.text)
# The repeat is collapsed rather than refused.
self.assertEqual(added.json()["added"], 2)
self.assertEqual(self.db.query(QuestionFolderQuestion).count(), 2)
listing = self.client.get(f"/folders/{folder_id}/questions").json()
self.assertEqual([row["id"] for row in listing], [1, 3])
self.assertEqual(self.client.get("/folders/").json()[0]["question_count"], 2)
def test_adding_the_same_question_twice_is_not_an_error(self):
folder_id = self.make()
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1]})
again = self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1, 2]})
self.assertEqual(again.json(), {"added": 1, "already_there": 1})
def test_a_folder_of_nothing_is_refused_and_so_is_a_missing_question(self):
folder_id = self.make()
self.assertEqual(self.client.post("/folders/", json={"name": " "}).status_code, 422)
self.assertEqual(
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [9999]}).status_code, 404)
def test_a_learner_with_no_editorial_access_has_no_folders(self):
self.bank.user = self.bank.peer
self.assertEqual(self.client.get("/folders/").status_code, 403)
self.assertEqual(self.client.post("/folders/", json={"name": "Mine"}).status_code, 403)
# ── grants ────────────────────────────────────────────────────────────────
def test_a_folder_grants_nothing_until_it_is_granted(self):
folder_id = self.make()
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1, 2]})
# The peer holds no grant at all, folder or otherwise.
self.assertEqual(self.reaches(self.bank.peer), set())
def test_a_granted_folder_reaches_exactly_its_contents(self):
folder_id = self.make()
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1, 6]})
granted = self.client.post(f"/access/{self.bank.peer.id}/grants",
json={"kind": "folder", "target_id": folder_id})
self.assertEqual(granted.status_code, 201, granted.text)
# Question 6 has no category at all, which is exactly what a branch
# grant cannot express and a folder can.
self.assertEqual(self.reaches(self.bank.peer), {1, 6})
def test_the_access_tree_lists_folders_and_says_who_holds_them(self):
folder_id = self.make("Cardiology remediation")
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1, 2]})
self.client.post(f"/access/{self.bank.peer.id}/grants",
json={"kind": "folder", "target_id": folder_id})
tree = self.client.get("/access/tree").json()
self.assertEqual(tree["folders"], [{"id": folder_id, "name": "Cardiology remediation",
"description": None, "questions": 2}])
rows = {row["id"]: row for row in self.client.get("/access/").json()["users"]}
self.assertEqual(rows[self.bank.peer.id]["folders"], [folder_id])
# A folder grant is not a branch, and must not be counted as one.
self.assertEqual(rows[self.bank.peer.id]["categories"], [])
def test_a_folder_grant_is_taken_back_on_its_own(self):
folder_id = self.make()
uid = self.bank.peer.id
self.client.post(f"/access/{uid}/grants", json={"kind": "folder", "target_id": folder_id})
self.assertEqual(self.client.delete(f"/access/{uid}/grants/folder/{folder_id}").status_code, 204)
self.assertEqual(self.db.query(CategoryGrant).filter_by(user_id=uid).count(), 0)
self.assertEqual(self.client.delete(f"/access/{uid}/grants/folder/{folder_id}").status_code, 404)
def test_granting_the_same_folder_twice_is_refused(self):
folder_id = self.make()
uid = self.bank.peer.id
self.client.post(f"/access/{uid}/grants", json={"kind": "folder", "target_id": folder_id})
self.assertEqual(
self.client.post(f"/access/{uid}/grants", json={"kind": "folder", "target_id": folder_id}).status_code, 409)
self.assertEqual(
self.client.post(f"/access/{uid}/grants", json={"kind": "folder", "target_id": 9999}).status_code, 404)
# ── nobody widens their own reach ─────────────────────────────────────────
def test_a_grantee_reads_the_folder_but_cannot_change_what_is_in_it(self):
folder_id = self.make()
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1]})
self.client.post(f"/access/{self.bank.peer.id}/grants",
json={"kind": "folder", "target_id": folder_id})
self.bank.user = self.bank.peer
self.assertEqual([row["id"] for row in self.client.get(f"/folders/{folder_id}/questions").json()], [1])
self.assertFalse(self.client.get("/folders/").json()[0]["can_edit"])
# Adding question 2 would hand the grantee a question nobody gave them.
self.assertEqual(
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [2]}).status_code, 403)
self.assertEqual(self.client.delete(f"/folders/{folder_id}/questions/1").status_code, 403)
self.assertEqual(self.client.delete(f"/folders/{folder_id}").status_code, 403)
def test_an_educator_may_only_file_questions_they_already_manage(self):
# A branch grant over category 3 (Leaf), which holds question 3 alone.
self.db.add(CategoryGrant(category_id=3, user_id=self.bank.peer.id))
self.db.commit()
self.bank.user = self.bank.peer
folder_id = self.make("My own")
self.assertEqual(
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [3]}).status_code, 200)
self.assertEqual(
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1]}).status_code, 403)
def test_a_folder_nobody_granted_is_invisible_to_other_educators(self):
folder_id = self.make()
self.db.add(CategoryGrant(category_id=3, user_id=self.bank.peer.id))
self.db.commit()
self.bank.user = self.bank.peer
self.assertEqual(self.client.get("/folders/").json(), [])
self.assertEqual(self.client.get(f"/folders/{folder_id}/questions").status_code, 404)
# ── deletion ──────────────────────────────────────────────────────────────
def test_deleting_a_folder_takes_its_grants_with_it(self):
folder_id = self.make()
uid = self.bank.peer.id
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [1]})
self.client.post(f"/access/{uid}/grants", json={"kind": "folder", "target_id": folder_id})
self.assertEqual(self.client.delete(f"/folders/{folder_id}").status_code, 204)
# No orphan grant left reading as access the person no longer has.
self.assertEqual(self.db.query(CategoryGrant).filter_by(user_id=uid).count(), 0)
self.assertEqual(self.db.query(QuestionFolder).count(), 0)
self.assertEqual(self.db.query(QuestionFolderQuestion).count(), 0)
self.assertEqual(self.reaches(self.bank.peer), set())
def test_the_bank_can_be_narrowed_to_one_folder(self):
folder_id = self.make()
self.client.post(f"/folders/{folder_id}/questions", json={"question_ids": [2, 3]})
bank = self.client.get("/questions/bank", params={"folder_id": folder_id}).json()
self.assertEqual({row["id"] for row in bank["questions"]}, {2, 3})
class GrantConstraintTests(unittest.TestCase):
"""The two Postgres constraints on `category_grants` that only a migration knows about.
`ck_grant_has_a_dimension` and `uq_grant_dimensions` are created by raw DDL
and appear on no model, so `create_all()` never builds them and the SQLite
suite above cannot see them. Left as they were, the check would reject a
grant naming only a folder, and the unique index would fold every folder
grant for one person onto the same key and call the second a duplicate.
Read from the migration source rather than from a database, because this
suite has no PostgreSQL to ask.
"""
def setUp(self):
from pathlib import Path
versions = Path(__file__).resolve().parents[1] / "alembic" / "versions"
self.sql = (versions / "i9f0a1b2c3d4_folders_notes_article_feedback.py").read_text()
def test_the_check_admits_a_grant_naming_only_a_folder(self):
check = self.sql.split("ADD CONSTRAINT ck_grant_has_a_dimension")[1].split('"""')[0]
self.assertIn("folder_id IS NOT NULL", check)
def test_the_unique_key_tells_two_folder_grants_apart(self):
index = self.sql.split("CREATE UNIQUE INDEX IF NOT EXISTS uq_grant_dimensions")[1].split('"""')[0]
self.assertIn("COALESCE(folder_id, 0)", index)
if __name__ == "__main__":
unittest.main()