There will be no courses. What was there: one draft called "jk" with two empty lessons, and 4,000 lines of code around it — courses, modules, lessons, enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates, three React pages, a router, two models. Its real cost was everywhere else. Every query that measured practice had to remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have silently mixed course attempts into a learner's analytics; the bank predicate carried a subquery to exclude a course's own questions from every search, recommendation and share; quiz access had a second, parallel rule about enrolment. All of that is gone, so the remaining rules say what they mean. `quizzes.allow_review` goes with it. It was only ever enforced for a course quiz, so it had become a promise nothing keeps — the public session page was still offering "no answer review" about sessions that review fine. The fixtures' question 5 lived in a course quiz and stood for "a question that exists but is not in your bank". There is no such thing now — a question is in the bank unless it is deleted — so the counts it kept out of the numbers are back in, and the tests that turned on it now turn on deletion or on the attempt that actually holds a question. Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in app/utils/upload_access.py is what keeps them unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
97 lines
4.3 KiB
Python
97 lines
4.3 KiB
Python
"""Questions, articles and flashcard decks all file under one category tree.
|
|
|
|
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
|
"""
|
|
import os
|
|
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
|
|
|
import unittest
|
|
|
|
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.article import Article
|
|
from app.models.flashcard import FlashcardDeck
|
|
from app.models.question import Question
|
|
from app.models.question_category import QuestionCategory
|
|
from app.models.user import User
|
|
from app.routers import flashcards
|
|
from app.utils.auth import get_current_user
|
|
|
|
|
|
class SharedCategoryTests(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="Owner", email="owner@example.test", hashed_password="unused")
|
|
self.db.add(self.user)
|
|
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=3, name="Neurology", user_id=1),
|
|
])
|
|
self.db.flush()
|
|
# One of each content type filed under the same tree.
|
|
self.db.add(Question(id=1, question_category_id=2, user_id=1,
|
|
question_text="A question", question_type="mcq",
|
|
options=["yes", "no"], correct_answer="yes"))
|
|
self.db.add(Article(id=1, slug="kawasaki", title="Kawasaki disease", sections=[],
|
|
category_id=2, user_id=1, status="published"))
|
|
self.db.add_all([
|
|
FlashcardDeck(id=1, title="Kawasaki cards", user_id=1, card_count=0, category_id=2),
|
|
FlashcardDeck(id=2, title="Neuro cards", user_id=1, card_count=0, category_id=3),
|
|
FlashcardDeck(id=3, title="Unfiled cards", user_id=1, card_count=0),
|
|
])
|
|
self.db.commit()
|
|
|
|
app = FastAPI()
|
|
app.include_router(flashcards.router, prefix="/flashcards")
|
|
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 test_all_three_content_types_use_the_same_category_ids(self):
|
|
question = self.db.get(Question, 1)
|
|
article = self.db.get(Article, 1)
|
|
deck = self.db.get(FlashcardDeck, 1)
|
|
self.assertEqual({question.question_category_id, article.category_id, deck.category_id}, {2})
|
|
|
|
def test_deck_list_filters_by_category_including_descendants(self):
|
|
titles = lambda params: {d["title"] for d in self.client.get("/flashcards/", params=params).json()}
|
|
self.assertEqual(titles({"category_id": 2}), {"Kawasaki cards"})
|
|
# A parent picks up everything filed beneath it.
|
|
self.assertEqual(titles({"category_id": 1}), {"Kawasaki cards"})
|
|
self.assertEqual(titles({"category_id": 3}), {"Neuro cards"})
|
|
self.assertEqual(titles({}), {"Kawasaki cards", "Neuro cards", "Unfiled cards"})
|
|
|
|
def test_a_deck_can_be_filed_and_unfiled(self):
|
|
response = self.client.patch("/flashcards/3", json={"category_id": 3})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()["category_id"], 3)
|
|
self.assertEqual(self.db.get(FlashcardDeck, 3).category_id, 3)
|
|
|
|
self.assertEqual(self.client.patch("/flashcards/3", json={"category_id": None}).json()["category_id"], None)
|
|
|
|
def test_filing_under_a_category_that_does_not_exist_is_refused(self):
|
|
self.assertEqual(self.client.patch("/flashcards/1", json={"category_id": 999}).status_code, 404)
|
|
self.assertEqual(self.db.get(FlashcardDeck, 1).category_id, 2)
|
|
|
|
def test_renaming_a_deck_leaves_its_category_alone(self):
|
|
self.client.patch("/flashcards/1", json={"title": "Renamed"})
|
|
deck = self.db.get(FlashcardDeck, 1)
|
|
self.assertEqual((deck.title, deck.category_id), ("Renamed", 2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|