pdf-quiz-generator/backend/tests/test_articles_cards.py
Daniel 831cb01650 feat: an article follows a topic, rather than copying it once
"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
2026-09-12 19:57:06 +02:00

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![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)