"""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()