"""Per-category editorial grants: a non-moderator educator edits only their categories. Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests No application startup, external services or AI calls; a disposable SQLite database per test. """ import os os.environ["DATABASE_URL"] = "sqlite:///:memory:" import unittest from unittest.mock import patch from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base, get_db from app.models.category_grant import CategoryGrant from app.models.question import Question from app.models.question_category import QuestionCategory, QuestionCategoryLink from app.models.user import User from app.routers import question_categories, questions from app.utils.auth import get_current_user class CategoryGrantTests(unittest.TestCase): def setUp(self): self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(self.engine) self.db = Session(self.engine) self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator") self.educator = User(id=2, name="Educator", email="edu@example.test", hashed_password="unused") self.outsider = User(id=3, name="Outsider", email="out@example.test", hashed_password="unused") self.db.add_all([self.mod, self.educator, self.outsider]) # Cardiology > Kawasaki disease · Neurology stands alone. self.db.add_all([ QuestionCategory(id=1, name="Cardiology", user_id=1), QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=1), QuestionCategory(id=10, name="Neurology", user_id=1), ]) self.db.flush() for qid, category in [(1, 1), (2, 2), (3, 10), (4, None)]: self.db.add(Question(id=qid, question_category_id=category, user_id=1, question_text=f"Question {qid}", question_type="mcq", options=["yes", "no"], correct_answer="yes", explanation="Because")) self.db.commit() self.user = self.mod app = FastAPI() app.include_router(questions.router, prefix="/questions") app.include_router(question_categories.router, prefix="/question-categories") app.dependency_overrides[get_db] = lambda: self.db app.dependency_overrides[get_current_user] = lambda: self.user self.client = TestClient(app) def tearDown(self): self.client.close() self.db.close() self.engine.dispose() def grant(self, category_id=1, user_id=2): self.db.add(CategoryGrant(category_id=category_id, user_id=user_id, granted_by=1)) self.db.commit() # ── Granting ─────────────────────────────────────────────────── def test_moderator_grants_and_revokes_a_category(self): response = self.client.post("/question-categories/1/grants", json={"user_id": 2}) self.assertEqual(response.status_code, 201, response.text) self.assertEqual(response.json()["category_name"], "Cardiology") self.assertEqual(response.json()["user_email"], "edu@example.test") listed = self.client.get("/question-categories/grants").json() self.assertEqual([(g["category_id"], g["user_id"]) for g in listed], [(1, 2)]) self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 409) self.assertEqual(self.client.delete("/question-categories/1/grants/2").status_code, 204) self.assertEqual(self.client.get("/question-categories/grants").json(), []) def test_grants_are_moderator_only_and_validate_their_target(self): self.assertEqual(self.client.post("/question-categories/999/grants", json={"user_id": 2}).status_code, 404) self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 999}).status_code, 404) # A moderator already manages everything; a grant would be meaningless. self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 1}).status_code, 400) self.user = self.educator self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 403) self.assertEqual(self.client.get("/question-categories/grants").status_code, 403) def test_my_grants_reports_the_scope_including_descendants(self): self.user = self.educator self.assertEqual(self.client.get("/question-categories/my-grants").json(), {"is_moderator": False, "can_manage_questions": False, "categories": []}) self.grant(category_id=1) scope = self.client.get("/question-categories/my-grants").json() self.assertTrue(scope["can_manage_questions"]) self.assertEqual([(c["name"], c["direct"]) for c in scope["categories"]], [("Cardiology", True), ("Kawasaki disease", False)]) self.user = self.mod self.assertTrue(self.client.get("/question-categories/my-grants").json()["is_moderator"]) # ── Using a grant ────────────────────────────────────────────── def test_educator_edits_inside_the_grant_and_its_descendants_only(self): self.grant(category_id=1) self.user = self.educator self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200) # Question 2 sits in the child category, still inside the grant. self.assertEqual(self.client.patch("/questions/2", json={"difficulty": "hard"}).status_code, 200) # Neurology and the uncategorised question are out of scope. self.assertEqual(self.client.patch("/questions/3", json={"explanation": "No"}).status_code, 403) self.assertEqual(self.client.patch("/questions/4", json={"explanation": "No"}).status_code, 403) self.assertEqual(self.db.get(Question, 3).explanation, "Because") def test_educator_cannot_move_a_question_out_of_their_scope(self): self.grant(category_id=1) self.user = self.educator self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 10}).status_code, 403) self.assertEqual(self.client.patch("/questions/1", json={"additional_category_ids": [10]}).status_code, 403) self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 2}).status_code, 200) self.assertEqual(self.db.get(Question, 1).question_category_id, 2) def test_educator_creates_only_inside_their_scope(self): self.grant(category_id=1) self.user = self.educator body = {"question_text": "New question", "question_type": "mcq", "options": ["yes", "no"], "correct_answer": "yes"} with patch("app.services.embedding_service.embed_question", return_value=None): allowed = self.client.post("/questions/create", json={**body, "question_category_id": 2}) self.assertEqual(allowed.status_code, 200, allowed.text) self.assertEqual(self.client.post("/questions/create", json={**body, "question_category_id": 10}).status_code, 403) # Uncategorised creation is out of scope too — it would land outside the grant. self.assertEqual(self.client.post("/questions/create", json=body).status_code, 403) def test_bulk_actions_are_confined_to_the_grant(self): self.grant(category_id=1) self.user = self.educator mixed = self.client.post("/questions/bulk", json={"question_ids": [1, 3], "action": "difficulty", "difficulty": "easy"}) self.assertEqual(mixed.status_code, 403) self.assertIsNone(self.db.get(Question, 1).difficulty) # nothing applied ok = self.client.post("/questions/bulk", json={"question_ids": [1, 2], "action": "difficulty", "difficulty": "easy"}) self.assertEqual(ok.status_code, 200, ok.text) self.assertEqual(self.db.get(Question, 1).difficulty, "easy") # Reassigning into an ungranted category is refused. self.assertEqual(self.client.post("/questions/bulk", json={"question_ids": [1], "action": "category", "category_id": 10}).status_code, 403) def test_additional_category_links_bring_a_question_into_scope(self): self.grant(category_id=10) self.db.add(QuestionCategoryLink(question_id=1, category_id=10)) self.db.commit() self.user = self.educator self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200) def test_educator_deletes_only_inside_the_grant(self): self.grant(category_id=1) self.user = self.educator self.assertEqual(self.client.delete("/questions/3").status_code, 403) self.assertEqual(self.client.delete("/questions/1").status_code, 204) # Deleting hides rather than erases — the row and its id have to # survive or nothing that points at them can be put back. self.db.expire_all() self.assertIsNotNone(self.db.get(Question, 1).deleted_at) def test_summary_is_scoped_to_the_grant(self): self.grant(category_id=1) self.user = self.educator summary = self.client.get("/questions/manage/summary").json() self.assertTrue(summary["scoped"]) self.assertEqual(summary["total"], 2) # questions 1 and 2 only moderator_summary = None self.user = self.mod moderator_summary = self.client.get("/questions/manage/summary").json() self.assertFalse(moderator_summary["scoped"]) self.assertEqual(moderator_summary["total"], 4) def test_the_bank_listing_hands_the_answer_only_to_whoever_writes_it(self): """Any signed-in learner could read the whole answer key from /bank. The listing is the question manager's, but nothing stopped a learner calling it, and it returned the correct option, the explanation, the option explanations and the attending tip for every question in the bank. The stem is bank content; the answer beside it is not. """ self.user = self.outsider rows = self.client.get("/questions/bank").json()["questions"] self.assertEqual(len(rows), 4) # every stem is still listed for row in rows: self.assertTrue(row["question_text"]) self.assertIsNone(row["correct_answer"]) self.assertIsNone(row["explanation"]) self.assertIsNone(row["attending_tip"]) self.assertIsNone(row["explanation_image_path"]) # An educator sees the answer where their grant reaches, and only there. self.grant(category_id=1, user_id=3) self.user = self.outsider answers = {row["id"]: row["correct_answer"] for row in self.client.get("/questions/bank").json()["questions"]} self.assertEqual(answers[1], "yes") # Cardiology self.assertEqual(answers[2], "yes") # a descendant of it self.assertIsNone(answers[3]) # Neurology self.assertIsNone(answers[4]) # filed nowhere self.user = self.mod self.assertTrue(all(row["correct_answer"] == "yes" for row in self.client.get("/questions/bank").json()["questions"])) def test_ungranted_user_is_locked_out_of_question_management(self): self.user = self.outsider self.assertEqual(self.client.get("/questions/manage/summary").status_code, 403) self.assertEqual(self.client.patch("/questions/1", json={"explanation": "No"}).status_code, 403) self.assertEqual(self.client.post("/questions/bulk", json={"question_ids": [1], "action": "delete"}).status_code, 403) if __name__ == "__main__": unittest.main()