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