pdf-quiz-generator/backend/tests/test_feedback.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

171 lines
8 KiB
Python

"""Feedback on a question: reported by a learner, worked through by an educator.
Disposable SQLite. The rules worth pinning: any learner may report, only
someone whose grants cover *that question* may read or answer, resolving keeps
the report, the badge answers quietly for someone with no access rather than
refusing, and the learner can read the reply that was written to them.
"""
import unittest
import test_quiz_builder as fixtures
from app.models.category_grant import CategoryGrant
from app.models.feedback import QuestionFeedback
from app.routers import feedback
class FeedbackTests(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(feedback.router, prefix="/feedback")
self.bank.user = self.bank.owner
def tearDown(self):
self.bank.tearDown()
def send(self, question_id=1, message="The answer key looks wrong."):
return self.client.post(f"/feedback/questions/{question_id}", json={"message": message})
def test_any_learner_may_report_and_it_names_the_question(self):
self.bank.user = self.bank.peer
response = self.send()
self.assertEqual(response.status_code, 201, response.text)
row = self.db.query(QuestionFeedback).one()
self.assertEqual((row.question_id, row.user_id, row.status), (1, self.bank.peer.id, "open"))
def test_a_report_about_nothing_is_refused(self):
self.assertEqual(self.send(question_id=9999).status_code, 404)
self.assertEqual(self.client.post("/feedback/questions/1", json={"message": "no"}).status_code, 422)
def test_only_an_educator_may_read_or_answer(self):
self.send()
row = self.db.query(QuestionFeedback).one()
self.bank.user = self.bank.peer
self.assertEqual(self.client.get("/feedback/questions/1").status_code, 403)
self.assertEqual(self.client.patch(f"/feedback/{row.id}", json={"status": "resolved"}).status_code, 403)
self.assertEqual(self.client.delete(f"/feedback/{row.id}").status_code, 403)
def test_the_badge_answers_quietly_for_someone_with_no_access(self):
self.send()
self.bank.user = self.bank.peer
body = self.client.get("/feedback/open").json()
# Not a 403: the header asks for this without first asking who is asking.
self.assertEqual(body, {"open": 0, "items": []})
def test_an_educator_sees_what_is_outstanding_with_the_question_it_is_about(self):
self.send()
self.bank.user = self.bank.mod
body = self.client.get("/feedback/open").json()
self.assertEqual(body["open"], 1)
self.assertEqual(body["items"][0]["question_id"], 1)
self.assertTrue(body["items"][0]["question_excerpt"])
self.assertEqual(body["items"][0]["from_name"], self.bank.owner.name)
def test_replying_resolves_and_keeps_the_report(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
answered = self.client.patch(f"/feedback/{row_id}",
json={"reply": "Fixed, thank you.", "status": "resolved"})
self.assertEqual(answered.status_code, 200, answered.text)
self.assertEqual(answered.json()["reply"], "Fixed, thank you.")
# Kept, not deleted: a question with a history of the same complaint
# should visibly have one.
self.db.expire_all()
self.assertEqual(self.db.query(QuestionFeedback).count(), 1)
self.assertEqual(self.client.get("/feedback/open").json()["open"], 0)
def test_a_resolved_report_can_be_reopened(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
self.client.patch(f"/feedback/{row_id}", json={"status": "resolved"})
self.client.patch(f"/feedback/{row_id}", json={"status": "open"})
self.assertEqual(self.client.get("/feedback/open").json()["open"], 1)
self.assertEqual(self.client.patch(f"/feedback/{row_id}", json={"status": "maybe"}).status_code, 400)
def test_deleting_removes_it(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f"/feedback/{row_id}").status_code, 204)
self.assertEqual(self.db.query(QuestionFeedback).count(), 0)
def test_an_educator_may_not_answer_outside_their_grant(self):
"""Holding a grant is not the same as holding one over this question.
The first version gated reading, replying and deleting on having any
grant at all, so an educator given one branch could work through — and
delete — reports about the whole bank.
"""
self.send(question_id=6)
row_id = self.db.query(QuestionFeedback).one().id
self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id))
self.db.commit()
self.bank.user = self.bank.peer
self.assertEqual(self.client.get("/feedback/questions/6").status_code, 403)
self.assertEqual(self.client.patch(f"/feedback/{row_id}", json={"status": "resolved"}).status_code, 403)
self.assertEqual(self.client.delete(f"/feedback/{row_id}").status_code, 403)
self.db.expire_all()
self.assertEqual(self.db.query(QuestionFeedback).count(), 1)
def test_an_additional_category_link_still_reaches_the_educator(self):
"""A report must not become nobody's because of how the question is filed.
The queue used to be narrowed by primary category alone, so a question
reachable only through an additional link was invisible to the person
holding the grant that covers it.
"""
from app.models.question_category import QuestionCategoryLink
self.db.add(QuestionCategoryLink(question_id=6, category_id=3))
self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id))
self.db.commit()
self.send(question_id=6)
self.bank.user = self.bank.peer
body = self.client.get("/feedback/open").json()
self.assertEqual([item["question_id"] for item in body["items"]], [6])
self.assertEqual(self.client.get("/feedback/questions/6").status_code, 200)
def test_the_learner_can_read_the_reply_that_was_written_to_them(self):
self.send()
row_id = self.db.query(QuestionFeedback).one().id
self.bank.user = self.bank.mod
self.client.patch(f"/feedback/{row_id}", json={"reply": "Key corrected.", "status": "resolved"})
self.bank.user = self.bank.owner
mine = self.client.get("/feedback/questions/1/mine").json()
self.assertEqual(len(mine), 1)
self.assertEqual((mine[0]["reply"], mine[0]["status"]), ("Key corrected.", "resolved"))
# Somebody else's report is not the learner's to read.
self.bank.user = self.bank.peer
self.assertEqual(self.client.get("/feedback/questions/1/mine").json(), [])
def test_every_item_says_where_to_go_and_what_kind_it_is(self):
self.send()
self.bank.user = self.bank.mod
item = self.client.get("/feedback/open").json()["items"][0]
self.assertEqual((item["kind"], item["href"]), ("question", "/questions/1"))
def test_a_granted_educator_sees_only_their_own_branch(self):
# Questions 1 and 2 sit under the Root branch; 6 is filed nowhere, so
# no grant over the tree can reach it.
self.send(question_id=1)
self.send(question_id=6)
self.db.add(CategoryGrant(category_id=1, user_id=self.bank.peer.id))
self.db.commit()
self.bank.user = self.bank.peer
body = self.client.get("/feedback/open").json()
self.assertEqual(body["open"], 1)
self.assertEqual(body["items"][0]["question_id"], 1)
# A moderator sees both, including the one filed nowhere.
self.bank.user = self.bank.mod
self.assertEqual(self.client.get("/feedback/open").json()["open"], 2)
if __name__ == "__main__":
unittest.main()