pdf-quiz-generator/backend/tests/test_shared_category.py
Daniel febb14490c
Some checks failed
Tests / backend (push) Failing after 15s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 26s
feat: ground AI drafts in the library and PubMed, and mend the card system
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.

- *The clinical library.* The indexed shelf the clinical assistant already
  searches, over MCP on the internal network. Ported from ped-ai: sessions are
  reused, a dead one is reopened once, and a library that cannot be reached
  never fails the article — it just means the educator is writing without it,
  and the progress line says so.
- *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the
  two lessons that cost somebody an afternoon over there: PubMed ANDs every
  term, so "bronchiolitis management in infants" can find nothing where
  "bronchiolitis management" finds six — hence the query ladder — and three
  esearch calls in a row will trip the rate limit, hence the spacing. The
  reference list is written from the records rather than by the model, so every
  line is a paper that exists with a PMID somebody can look up.

Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text
citations, in one draft.

**The card system, which turned out to be half-built:**

- There was no way to make a deck by hand, and no way to edit a card at all —
  you could browse, view and delete. Both are there now, the editor taking
  front, back and a picture.
- Filing, writing, sharing and deleting are all educator work now, behind one
  named gate rather than four scattered checks. A learner studies.
- A deck generated from an article inherits that article's category instead of
  landing in Uncategorized for somebody to file by hand.
- A link inside a card previewed instead of going. A card is a box a few lines
  tall, often inside a flipping panel, and a hover card anchored in one is
  clipped by it — so the link read as broken because clicking it did nothing.
  Where there is no room to preview, the honest behaviour is to take you there.

**An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so
a drafted article was neither "generated, unread" nor anything else: the tile
counted it and there was nowhere to click. Drafts are stamped with the model
that wrote them, and there is now a plain Drafts queue that cannot be fallen
through.

**The sign-in code email** is laid out rather than written: the code is the
biggest thing on the screen, then which account it signs into, then a way back
to the page, then permission to ignore the whole thing.

Also: a back link out of a deck, in the same words as the rest of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:44:42 +02:00

101 lines
4.6 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)
# A moderator, because filing and renaming a deck is an educator's
# job: cards are study material, and who may write them is not decided
# by who happens to own the row.
self.user = User(id=1, name="Owner", email="owner@example.test",
hashed_password="unused", role="moderator")
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()