"Questions filed there later are not added" was the honest description of what the previous commit built, and it was the wrong thing to build. "The Cardiology article covers the Cardiology questions" is a standing statement about the material, not a snapshot of who happened to be filed where on the afternoon somebody pressed a button — and a copy stops being true the first time a question is added, silently, with nothing on any screen to say so. So the claim is now stored, and it is what writes the links: * `question_article_links` is still the **only** table anything reads. No count, no QBank button, no mirror panel on a question, no AI Mode boost learns a second question to ask. * `article_topic_claims` records *why* some of those rows exist, and is the one place that makes them — when the claim is staked, when a question is filed into the category (single, bulk, or on create), and on a half-hourly sweep that catches whatever bypassed both. A link made this way is an ordinary row and can still be deleted by hand; a sweep puts it back, which is the honest consequence of a standing claim. Dropping the claim is how you stop it, and the panel now lists what an article follows with two ways out — stop following and keep the links, or stop and remove them. Migration k1b2c3d4e5f6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
493 lines
29 KiB
Python
493 lines
29 KiB
Python
"""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 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)
|