`Question.is_shared` defaulted to 1 and was only ever set by a route nothing called, so in practice it divided the bank into "everything" and "everything, plus your own private ones" — a distinction that cost every recommendation denominator a join and never changed an answer. Who may reach the bank is the site's own access rules; who may manage a question is the category grant tree. So the two predicates the whole bank was built on are now the same thing, and say what they actually mean: a question is out of reach if it has been deleted or belongs to a course. Nothing else. The column is dropped, the route that set it is gone, the bulk "share" action with it, and the Private tile and pill go from the question manager. The tests that turned on it have been rewritten rather than deleted, because the rule they were really about survives: revoking a question still revokes every session carrying it — by deleting it, which is the only revocation left. Several others named a category holding exactly two reachable questions and then answered two particular ids; that category holds four now, so they name the pair instead. A session's own sharing flag is untouched — that is a different thing, and it is still how a session is handed to somebody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
121 lines
5.5 KiB
Python
121 lines
5.5 KiB
Python
"""AI-mode entry points build tests from existing bank questions, never new ones.
|
|
|
|
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
|
"""
|
|
import os
|
|
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
|
|
|
import io
|
|
from datetime import datetime
|
|
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.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
|
|
from app.models.question import Question
|
|
from app.models.quiz import Quiz
|
|
from app.models.user import User
|
|
from app.routers import questions
|
|
from app.utils.auth import get_current_user
|
|
|
|
|
|
class AiModeMatchingTests(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.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
|
|
self.other = User(id=2, name="Other", email="other@example.test", hashed_password="unused")
|
|
self.db.add_all([self.user, self.other])
|
|
# A question that nobody may match against is a deleted one; the flag
|
|
# an author could set to keep one back no longer exists.
|
|
for qid, text, owner, deleted in [
|
|
(1, "A child with fever and a seizure", 1, None),
|
|
(2, "An infant with jaundice", 1, None),
|
|
(3, "A question of someone else, since removed", 2, datetime(2026, 1, 1)),
|
|
]:
|
|
self.db.add(Question(id=qid, user_id=owner, deleted_at=deleted, question_text=text,
|
|
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
|
|
self.db.commit()
|
|
|
|
app = FastAPI()
|
|
app.include_router(questions.router, prefix="/questions")
|
|
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 describe(self, **body):
|
|
# Wording overlaps the stems literally: the SQLite fallback has no
|
|
# stemming, so "seizures" would not reach "seizure" as Postgres would.
|
|
return self.client.post("/questions/builder/describe",
|
|
json={"text": "I want to study fever and seizure in a child", **body})
|
|
|
|
def test_a_description_builds_a_test_from_existing_bank_questions(self):
|
|
before = self.db.query(Question).count()
|
|
response = self.describe(count=5)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
body = response.json()
|
|
self.assertGreater(body["questions_count"], 0)
|
|
# Matching must never invent questions.
|
|
self.assertEqual(self.db.query(Question).count(), before)
|
|
quiz = self.db.get(Quiz, body["id"])
|
|
self.assertIsNotNone(quiz)
|
|
|
|
def test_matches_exclude_questions_the_user_cannot_see(self):
|
|
with patch.object(questions, "hybrid_ids", return_value=([3, 1], set())):
|
|
response = self.describe(count=5)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
# Question 3 belongs to someone else and is private, so only 1 survives.
|
|
self.assertEqual(response.json()["matched"], 1)
|
|
|
|
def test_no_match_is_reported_rather_than_returning_an_empty_test(self):
|
|
with patch.object(questions, "hybrid_ids", return_value=([], set())):
|
|
response = self.describe()
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn("match", response.json()["detail"].lower())
|
|
|
|
def test_description_and_count_are_bounded(self):
|
|
self.assertEqual(self.describe(count=0).status_code, 422)
|
|
self.assertEqual(self.describe(count=999).status_code, 422)
|
|
self.assertEqual(self.client.post("/questions/builder/describe",
|
|
json={"text": "short"}).status_code, 422)
|
|
|
|
def upload(self, content=b"febrile seizure in a toddler with fever, and jaundice in a newborn infant",
|
|
filename="notes.txt", **data):
|
|
return self.client.post("/questions/builder/from-upload",
|
|
files={"file": (filename, io.BytesIO(content), "text/plain")},
|
|
data={"count": "5", "mode": "learning", **data})
|
|
|
|
def test_an_upload_is_matched_against_the_bank_and_not_stored(self):
|
|
response = self.upload()
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertGreater(response.json()["questions_count"], 0)
|
|
# The document is a query, so nothing new is persisted.
|
|
self.assertEqual(self.db.query(Question).count(), 3)
|
|
|
|
def test_upload_limits_are_enforced(self):
|
|
oversized = b"x" * (questions.MAX_UPLOAD_BYTES + 10)
|
|
self.assertEqual(self.upload(content=oversized).status_code, 413)
|
|
self.assertEqual(self.upload(content=b"").status_code, 400)
|
|
self.assertEqual(self.upload(content=b"too short").status_code, 400)
|
|
self.assertEqual(self.upload(count="99").status_code, 400)
|
|
self.assertEqual(self.upload(mode="nonsense").status_code, 400)
|
|
|
|
def test_a_matched_test_is_capped(self):
|
|
self.assertLessEqual(questions.MAX_MATCHED_QUESTIONS, 30)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|