"""Article library and card association routes on disposable SQLite; no network/AI.""" import unittest from datetime import datetime import test_quiz_builder as fixtures from app.models.article import Article, QuestionArticleLink from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardQuestionLink, FlashcardArticleLink from app.models.question import Question from app.routers import articles, flashcards class ArticlesCardsTests(unittest.TestCase): def setUp(self): self.bank = fixtures.BuilderTests() self.bank.setUp() self.client = self.bank.client self.client.app.include_router(articles.router, prefix='/articles') self.client.app.include_router(flashcards.router, prefix='/flashcards') def tearDown(self): self.bank.tearDown() def create(self, **overrides): payload = {"title": "Topic article", "slug": "topic-article", "summary": "Summary", "content": "Intro", "sections": [{"id": "a" * 32, "slug": "first-section", "title": "First section", "content": "Body"}], **overrides} return self.client.post('/articles/', json=payload) def test_article_status_visibility_and_editing(self): self.bank.user = self.bank.mod article = self.create().json() self.assertIn('a' * 32, [s['id'] for s in article['sections']]) self.bank.user = self.bank.owner self.assertEqual(self.client.get('/articles/').json(), []) self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404) self.bank.user = self.bank.mod self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) self.bank.user = self.bank.owner listing = self.client.get('/articles/').json() self.assertEqual([a['id'] for a in listing], [article['id']]) detail = self.client.get(f"/articles/{article['id']}").json() self.assertEqual(detail['category_breadcrumbs'], []) self.bank.user = self.bank.peer self.assertEqual(self.client.patch(f"/articles/{article['id']}", json={ "title": "Topic article", "slug": "topic-article", "content": "Intro", "sections": []}).status_code, 403) self.assertEqual(self.client.delete(f"/articles/{article['id']}").status_code, 403) self.bank.user = self.bank.mod for payload in [{"title": " ", "slug": "x"}, {"title": "T", "slug": "Bad Slug"}, {"title": "T", "slug": "ok", "sections": [{"id": "short", "slug": "s", "title": "S", "content": ""}]}, {"title": "T", "slug": "ok", "sections": [{"id": "a" * 32, "slug": "dup", "title": "A"}, {"id": "b" * 32, "slug": "dup", "title": "B"}]}]: self.assertIn(self.create(**payload).status_code, (400, 422), payload) self.assertEqual(self.create(slug='topic-article').status_code, 400) self.assertEqual(self.client.post(f"/articles/{article['id']}/publish", json={'published': False}).status_code, 200) self.bank.user = self.bank.owner self.assertEqual(self.client.get('/articles/').json(), []) self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404) def test_a_whole_topic_can_be_linked_at_once(self): """One question at a time is right for a cross-reference and wrong for "every Cardiology question belongs to the Cardiology article".""" self.bank.user = self.bank.mod article = self.create().json() article_id = article['id'] # The count comes first, so the button can carry the number rather than # the educator guessing at what they are about to do. preview = self.client.get(f"/articles/{article_id}/links/from-category", params={'category_id': 2}).json() self.assertEqual(preview['would_link'], preview['total']) self.assertEqual(preview['already_linked'], 0) self.assertGreater(preview['total'], 1) done = self.client.post(f"/articles/{article_id}/links/from-category", json={'category_id': 2}).json() self.assertEqual(done['linked'], preview['total']) linked = self.client.get(f"/articles/{article_id}/questions").json() self.assertEqual(len(linked), preview['total']) # Twice is not twice as many links. Every one is already here. again = self.client.post(f"/articles/{article_id}/links/from-category", json={'category_id': 2}).json() self.assertEqual(again['linked'], 0) self.assertEqual(again['skipped'], preview['total']) self.assertEqual(len(self.client.get(f"/articles/{article_id}/questions").json()), preview['total']) # A parent takes its subtopics with it, which is what an educator means # by the name of a discipline — and does not without the flag. root = self.client.get(f"/articles/{article_id}/links/from-category", params={'category_id': 1}).json() alone = self.client.get(f"/articles/{article_id}/links/from-category", params={'category_id': 1, 'include_subtopics': False}).json() self.assertGreater(root['total'], alone['total']) # The same questions may be linked again against a section: a link to # the whole article and a link to one section are different links. section_id = article['sections'][0]['id'] deeper = self.client.post(f"/articles/{article_id}/links/from-category", json={'category_id': 2, 'section_id': section_id}).json() self.assertEqual(deeper['linked'], preview['total']) # The claim is standing, not a copy. A question filed into the topic # afterwards is linked without anybody pressing anything again, which # is the whole difference between "covers this topic" and "covered it # in September". from app.models.question import Question newcomer = Question(question_category_id=2, user_id=3, question_text="Filed later", question_type="mcq", options=["yes", "no"], correct_answer="yes") self.bank.db.add(newcomer) self.bank.db.commit() # Two links, because by now there are two claims on this topic — one on # the whole article and one on its first section — and a claim means # what it says wherever it points. self.assertEqual(self.client.post('/questions/bulk-category', json={ 'question_ids': [newcomer.id], 'category_id': 2}).json()['articles_linked'], 2) linked_now = self.client.get(f"/articles/{article_id}/questions").json() self.assertIn(newcomer.id, [row['question_id'] for row in linked_now]) # And the claim is visible, with a way to stop. claims = self.client.get(f"/articles/{article_id}/claims").json() self.assertEqual([c['category_id'] for c in claims], [2, 2]) self.assertTrue(all(c['include_subtopics'] for c in claims)) whole_article = next(c for c in claims if c['section_id'] is None) self.client.delete(f"/articles/{article_id}/claims/{whole_article['id']}", params={'keep_links': 'false'}) left = self.client.get(f"/articles/{article_id}/questions").json() # The section-scoped claim's links survive; the whole-article ones went. self.assertTrue(all(row['section_id'] for row in left)) self.assertEqual(self.client.post(f"/articles/{article_id}/links/from-category", json={'category_id': 999}).status_code, 404) self.assertEqual(self.client.post(f"/articles/{article_id}/links/from-category", json={'category_id': 2, 'section_id': 'f' * 32}).status_code, 400) self.bank.user = self.bank.owner self.assertEqual(self.client.post(f"/articles/{article_id}/links/from-category", json={'category_id': 2}).status_code, 403) def test_section_stability_remediation_and_question_links(self): self.bank.user = self.bank.mod article = self.create().json() section_id = article['sections'][0]['id'] link = self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}) self.assertEqual(link.status_code, 200, link.text) self.assertEqual(link.json()['linked'], True) self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}).json()['linked'], False) self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': 'f' * 32}).status_code, 400) self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 999, 'section_id': section_id}).status_code, 404) self.bank.user = self.bank.owner self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1}).status_code, 403) self.bank.user = self.bank.mod self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) self.bank.user = self.bank.owner self.assertEqual(self.client.get('/questions/1/articles').json()[0]['section_id'], section_id) # Whole-article link survives section renames/removal of OTHER sections. self.bank.user = self.bank.mod self.client.put(f"/articles/{article['id']}/links", json={'question_id': 2}) patch = self.client.patch(f"/articles/{article['id']}", json={ "title": "Renamed", "slug": "renamed", "content": "Intro", "sections": [{"id": section_id, "slug": "renamed-section", "title": "Renamed section", "content": "New body"}]}) self.assertEqual(patch.status_code, 200, patch.text) self.assertEqual(self.bank.db.query(QuestionArticleLink).filter_by(article_id=article['id']).count(), 2) # Removing the section remediates only that section link. self.client.patch(f"/articles/{article['id']}", json={ "title": "Renamed", "slug": "renamed", "content": "Intro", "sections": [{"id": "c" * 32, "slug": "other", "title": "Other", "content": ""}]}) links = self.bank.db.query(QuestionArticleLink).filter_by(article_id=article['id']).all() self.assertEqual({(l.question_id, l.section_id) for l in links}, {(2, None)}) self.bank.user = self.bank.peer linked = self.client.get('/questions/2/articles').json() self.assertEqual([l['section_id'] for l in linked], [None]) published_questions = self.client.get(f"/articles/{article['id']}/questions") self.assertEqual(published_questions.status_code, 200) self.assertEqual([q['question_id'] for q in published_questions.json()], [2]) def test_article_questions_visibility_and_publish(self): self.bank.user = self.bank.mod article = self.create().json() self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1}) self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) self.bank.user = self.bank.owner questions = self.client.get(f"/articles/{article['id']}/questions").json() self.assertEqual([q['question_id'] for q in questions], [1]) self.bank.db.get(Question, 1).deleted_at = datetime(2026, 1, 1) self.bank.db.commit() self.bank.user = self.bank.peer self.assertEqual(self.client.get(f"/articles/{article['id']}/questions").json(), []) self.bank.user = self.bank.mod self.assertEqual(len(self.client.get(f"/articles/{article['id']}/questions").json()), 1) def test_question_articles_mirrors_article_questions(self): """The same link, read from either end, must describe itself the same way.""" self.bank.user = self.bank.mod article = self.create().json() section_id = article['sections'][0]['id'] self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}) self.client.put(f"/articles/{article['id']}/links", json={'question_id': 2}) from_question = self.client.get('/questions/1/articles') self.assertEqual(from_question.status_code, 200, from_question.text) self.assertEqual(from_question.json(), [{ 'article_id': article['id'], 'title': 'Topic article', 'slug': 'topic-article', 'summary': 'Summary', 'status': 'draft', 'section_id': section_id, 'section_title': 'First section'}]) from_article = self.client.get(f"/articles/{article['id']}/questions").json() self.assertEqual({(q['section_id'], q['section_title']) for q in from_article}, {(section_id, 'First section'), (None, None)}) # A whole-article link names no section at either end. self.assertEqual([(a['section_id'], a['section_title']) for a in self.client.get('/questions/2/articles').json()], [(None, None)]) # A draft is educator-only, so a learner following the question finds nothing. self.bank.user = self.bank.peer self.assertEqual(self.client.get('/questions/1/articles').json(), []) self.bank.user = self.bank.mod self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) self.bank.user = self.bank.peer self.assertEqual([a['article_id'] for a in self.client.get('/questions/1/articles').json()], [article['id']]) self.assertEqual(self.client.get('/questions/999/articles').status_code, 404) # A link whose section is later deleted still points at the article. self.bank.user = self.bank.mod self.client.patch(f"/articles/{article['id']}", json={ "title": "Topic article", "slug": "topic-article", "content": "Intro", "sections": [{"id": "c" * 32, "slug": "other", "title": "Other", "content": ""}]}) self.assertEqual([a['section_title'] for a in self.client.get('/questions/2/articles').json()], [None]) def test_manual_cards_links_and_target_listing(self): deck = FlashcardDeck(user_id=3, title='Educator deck', is_shared=0) private = FlashcardDeck(user_id=2, title='Private deck', is_shared=0) self.bank.db.add_all([deck, private]) self.bank.db.flush() self.bank.db.commit() self.bank.user = self.bank.mod response = self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'Front', 'back': 'Back'}) self.assertEqual(response.status_code, 200, response.text) card = response.json() self.assertEqual(self.bank.db.get(FlashcardDeck, deck.id).card_count, 1) self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': '', 'back': ''}).status_code, 400) article = self.create().json() self.bank.user = self.bank.owner self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'F', 'back': 'B'}).status_code, 403) self.bank.user = self.bank.mod self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 1}).status_code, 200) self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 999}).status_code, 404) self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article", json={'article_id': article['id'], 'article_section_id': 'f' * 32}).status_code, 400) self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article", json={'article_id': article['id']}).status_code, 200) links = self.client.get(f"/flashcards/cards/{card['id']}/links").json() self.assertEqual([q['id'] for q in links['questions']], [1]) self.assertEqual([a['id'] for a in links['articles']], [article['id']]) self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) # Before sharing, peers cannot see links or linked cards; mutating links stays owner/admin-only. self.bank.user = self.bank.peer self.assertEqual(self.client.get('/flashcards/cards/linked', params={'question_id': 1}).json(), []) self.assertEqual(self.client.get(f"/flashcards/cards/{card['id']}/links").status_code, 403) self.assertEqual(self.client.delete(f"/flashcards/cards/{card['id']}/links/question/1").status_code, 403) # Sharing the deck grants read access to linked content. self.bank.db.get(FlashcardDeck, deck.id).is_shared = 1 self.bank.db.commit() self.bank.user = self.bank.owner self.assertEqual([c['id'] for c in self.client.get('/flashcards/cards/linked', params={'question_id': 1}).json()], [card['id']]) self.assertEqual([c['id'] for c in self.client.get('/flashcards/cards/linked', params={'article_id': article['id']}).json()], [card['id']]) self.bank.user = self.bank.mod self.assertEqual(self.client.delete(f"/flashcards/cards/{card['id']}/links/question/1").status_code, 204) self.assertEqual(self.bank.db.query(FlashcardQuestionLink).count(), 0) self.client.delete(f"/flashcards/cards/{card['id']}/links/article/{article['id']}") self.assertEqual(self.bank.db.query(FlashcardArticleLink).count(), 0) self.assertEqual(self.client.get('/flashcards/cards/linked', params={'question_id': 1, 'article_id': 1}).status_code, 400) # Deleting an article cascades its links. self.client.delete(f"/articles/{article['id']}") self.assertEqual(self.bank.db.query(QuestionArticleLink).count(), 0) def test_card_links_hide_private_questions_and_draft_articles(self): deck = FlashcardDeck(user_id=3, title='Shared educator deck', is_shared=1) self.bank.db.add(deck) self.bank.db.flush() card = Flashcard(deck_id=deck.id, front='Private front', back='Private back') self.bank.db.add(card) self.bank.db.flush() self.bank.user = self.bank.mod article = self.create().json() # Draft, owned by the moderator. self.client.put(f"/flashcards/cards/{card.id}/links/question", json={'question_id': 1}) self.client.put(f"/flashcards/cards/{card.id}/links/article", json={'article_id': article['id']}) # Drafts stay hidden from learners even though the shared deck exposes the card. self.bank.user = self.bank.owner links = self.client.get(f"/flashcards/cards/{card.id}/links").json() self.assertEqual([q['id'] for q in links['questions']], [1]) self.assertEqual(links['articles'], []) # Making the question private and publishing the article flips both filters. self.bank.db.get(Question, 1).deleted_at = datetime(2026, 1, 1) self.bank.db.commit() self.bank.user = self.bank.mod self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) self.bank.user = self.bank.owner links = self.client.get(f"/flashcards/cards/{card.id}/links").json() self.assertEqual(links['questions'], []) self.assertEqual([a['id'] for a in links['articles']], [article['id']]) self.bank.user = self.bank.peer links = self.client.get(f"/flashcards/cards/{card.id}/links").json() self.assertEqual(links['questions'], []) self.assertEqual([a['id'] for a in links['articles']], [article['id']]) self.bank.user = self.bank.mod links = self.client.get(f"/flashcards/cards/{card.id}/links").json() self.assertEqual([q['id'] for q in links['questions']], [1]) self.assertEqual([a['id'] for a in links['articles']], [article['id']]) def test_source_section_validation_rejects_unknown_ids(self): self.bank.user = self.bank.mod response = self.create(section_id=999) self.assertEqual(response.status_code, 400, response.text) article = self.create().json() response = self.client.patch(f"/articles/{article['id']}", json={ "title": "Topic article", "slug": "topic-article", "content": "Intro", "sections": [], "section_id": 999}) self.assertEqual(response.status_code, 400, response.text) if __name__ == '__main__': unittest.main() class ArticleReadingTests(unittest.TestCase): """Nested sections and the preview a cross-reference hover shows.""" def setUp(self): self.bank = fixtures.BuilderTests() self.bank.setUp() self.client = self.bank.client self.client.app.include_router(articles.router, prefix='/articles') self.bank.user = self.bank.mod def tearDown(self): self.bank.tearDown() def make(self, sections, slug="nested-topic", **extra): payload = {"title": "Nested topic", "slug": slug, "summary": "Summary", "content": "Intro", "sections": sections, **extra} return self.client.post('/articles/', json=payload) def section(self, letter, title, parent=None): body = {"id": letter * 32, "slug": f"sec-{letter}", "title": title, "content": f"{title} body"} if parent: body["parent_id"] = parent * 32 return body def test_a_section_may_sit_under_an_earlier_one(self): response = self.make([self.section('a', 'Review of systems'), self.section('b', 'ROS questionnaire', parent='a')]) self.assertEqual(response.status_code, 200, response.text) sections = response.json()['sections'] self.assertEqual([s.get('parent_id') for s in sections], [None, 'a' * 32]) def test_articles_written_before_nesting_stay_valid(self): # No parent_id at all is the shape every existing article has. response = self.make([{"id": "a" * 32, "slug": "s", "title": "S", "content": "B"}]) self.assertEqual(response.status_code, 200, response.text) self.assertIsNone(response.json()['sections'][0].get('parent_id')) def test_nesting_that_would_not_render_is_refused(self): cases = { "its own parent": [self.section('a', 'A', parent='a')], "a parent that comes later": [self.section('a', 'A', parent='b'), self.section('b', 'B')], "a parent outside the article": [self.section('a', 'A', parent='f')], # Two levels would need a heading level the reading view does not have. "a sub-section of a sub-section": [self.section('a', 'A'), self.section('b', 'B', parent='a'), self.section('c', 'C', parent='b')], } for label, sections in cases.items(): with self.subTest(label): self.assertEqual(self.make(sections, slug=f"case-{len(label)}").status_code, 400) def test_preview_is_small_and_respects_who_may_read_it(self): article = self.make([self.section('a', 'A')], slug='preview-topic').json() # A draft is not previewable by someone who cannot open it, or the hover # card would leak the title of unpublished work. self.bank.user = self.bank.owner self.assertEqual(self.client.get('/articles/preview/preview-topic').status_code, 404) self.bank.user = self.bank.mod self.client.post(f"/articles/{article['id']}/publish", json={'published': True}) self.bank.user = self.bank.owner preview = self.client.get('/articles/preview/preview-topic').json() self.assertEqual(preview['title'], 'Nested topic') self.assertEqual(preview['section_count'], 1) self.assertEqual(preview['excerpt'], 'Summary') # Deliberately not the whole article: a preview that carried the body # would pull the library down a paragraph at a time as somebody reads. self.assertNotIn('sections', preview) self.assertNotIn('content', preview) self.assertEqual(self.client.get('/articles/preview/no-such-topic').status_code, 404) def test_excerpt_reads_as_prose_not_markup(self): self.make([self.section('a', 'A')], slug='marked-up', summary='', content='## Heading\n\n![scan](/uploads/x.png) See [the workup](/articles/workup) **now**, ' 'stratified by [[288|eczema]] and [[Croup|croup]].') self.client.post('/articles/1/publish', json={'published': True}) excerpt = self.client.get('/articles/preview/marked-up').json()['excerpt'] self.assertNotIn('!', excerpt) self.assertNotIn('##', excerpt) self.assertNotIn('/uploads/', excerpt) self.assertIn('the workup', excerpt) # a link keeps its words # And so does a cross-reference. The generic link rule does not know # this syntax, so it used to leave "[[288 eczema]]" on the card. self.assertNotIn('[[', excerpt) self.assertNotIn('288', excerpt) self.assertIn('eczema', excerpt) self.assertIn('Croup', excerpt) def test_all_three_views_round_trip_through_a_save(self): payload = {"title": "Nested topic", "slug": "three-views", "summary": "S", "content": "I", "sections": [ {"id": "a" * 32, "slug": "short-1", "title": "In short", "content": "- a bullet", "variant": "short"}, {"id": "b" * 32, "slug": "long-1", "title": "Definition", "content": "Body", "variant": "long"}, {"id": "c" * 32, "slug": "clin-1", "title": "Management", "content": "Give fluids", "variant": "clinical"}, ]} created = self.client.post('/articles/', json=payload).json() self.assertEqual(created["variants"], ["short", "long", "clinical"]) # Editing one view leaves the other two exactly as they were. payload["sections"][1]["content"] = "A better definition" updated = self.client.patch(f"/articles/{created['id']}", json=payload).json() by_variant = {s["variant"]: s["content"] for s in updated["sections"]} self.assertEqual(by_variant["long"], "A better definition") self.assertEqual(by_variant["short"], "- a bullet") self.assertEqual(by_variant["clinical"], "Give fluids") def test_a_sub_section_cannot_belong_to_another_view(self): response = self.make([ {"id": "a" * 32, "slug": "s1", "title": "Definition", "content": "B", "variant": "long"}, {"id": "b" * 32, "slug": "s2", "title": "Detail", "content": "B", "variant": "clinical", "parent_id": "a" * 32}, ], slug="mixed-nesting") self.assertEqual(response.status_code, 400, response.text) def test_references_are_editable_and_absence_leaves_them_alone(self): payload = {"title": "Refs", "slug": "refs-topic", "content": "I", "sections": [], "references": [{"title": "Nelson", "author": "Kliegman", "pages": [12, 13]}]} created = self.client.post('/articles/', json=payload).json() # Set on a save… updated = self.client.patch(f"/articles/{created['id']}", json={ **payload, "references": [{"title": "Mandell", "pages": [7]}]}).json() self.assertEqual([r["title"] for r in updated["references"]], ["Mandell"]) # …and a caller that does not mention them must not wipe them, or an # older client would silently strip the provenance generation attached. no_refs = dict(payload) no_refs.pop("references") kept = self.client.patch(f"/articles/{created['id']}", json=no_refs).json() self.assertEqual([r["title"] for r in kept["references"]], ["Mandell"]) def test_every_save_is_recoverable(self): payload = {"title": "Versioned", "slug": "versioned", "content": "First draft", "sections": [{"id": "a" * 32, "slug": "s", "title": "S", "content": "One", "variant": "long"}]} article = self.client.post('/articles/', json=payload).json() payload["content"] = "Second draft" payload["sections"][0]["content"] = "Two" self.client.patch(f"/articles/{article['id']}", json=payload) revisions = self.client.get(f"/articles/{article['id']}/revisions").json() self.assertEqual(len(revisions), 1) old = self.client.get(f"/articles/{article['id']}/revisions/{revisions[0]['id']}").json() self.assertEqual(old["content"], "First draft") restored = self.client.post( f"/articles/{article['id']}/revisions/{revisions[0]['id']}/restore").json() self.assertEqual(restored["content"], "First draft") self.assertEqual(restored["sections"][0]["content"], "One") # Restoring is itself a save: the version being left is kept, or the way # back from a mistaken restore is gone. after = self.client.get(f"/articles/{article['id']}/revisions").json() self.assertEqual(len(after), 2) self.assertIn("before restoring", after[0]["note"]) def test_history_is_not_a_way_into_someone_elses_draft(self): article = self.make([self.section('a', 'A')], slug='private-history').json() self.bank.user = self.bank.peer self.assertEqual(self.client.get(f"/articles/{article['id']}/revisions").status_code, 403) self.assertEqual(self.client.post( f"/articles/{article['id']}/revisions/1/restore").status_code, 403)