pdf-quiz-generator/backend/tests/test_study_plan_editing.py
Daniel 8a07703ee2 feat: study plans you can open, work through, and edit
Thirteen plans were seeded with an API to serve them and nothing that called it,
so the whole feature existed only in the database. Two pages and the editing
endpoints it was missing.

/study-plans lists the plans with progress stated in blocks — "3 of 6 blocks"
is something you can act on, where "50%" only tells you how you feel about it.
/study-plans/:id is one plan: each block shows Articles, then Sessions, in that
order, because that is the order the block is meant to be done in.

Reading is now part of a block (migration f4a5b6c7d8e9). "Mark as read" is the
learner's own claim and reversible — someone who ticks the wrong row should be
able to fix it without an educator, and progress nobody can correct stops being
trusted and then stops being used. It is a separate table from `article_views`
on purpose: opening an article is not the same claim as having finished with it.
A draft article attached to a block is listed for the educator who can open it
and left out for everyone else, rather than offered as a dead link.

Editing is inline on the learner's own page rather than a separate builder, so
the thing being changed and the thing a learner sees are the same object.
Moderators create (as a draft — an empty plan is not something to put in front
of anyone), rename, publish, delete; add, rename, reorder and remove blocks;
move questions between blocks of one plan; attach reading found by searching
rather than by id.

Two places where the obvious implementation leaves the data wrong, both tested:
deleting a block out of the middle shuffles the survivors down, or the next
insert collides with a position nothing occupies; and reordering parks every row
outside the range before writing the real positions, because (plan_id, position)
is unique and the first move would otherwise collide with a position still held.
A partial order is refused rather than half-applied.

166 backend, 188 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:10:39 +02:00

182 lines
10 KiB
Python

"""Editing study plans, and the reading attached to their blocks.
Disposable SQLite; no network or AI. The interesting cases are the ones where a
naive implementation leaves the data inconsistent: deleting a block out of the
middle, reordering through a unique constraint, and moving questions between
blocks of different plans.
"""
import unittest
import test_quiz_builder as fixtures
from app.models.article import Article
from app.models.study_plan import (
StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle,
)
from app.routers import study_plans
class StudyPlanEditingTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(study_plans.router, prefix='/study-plans')
self.db = self.bank.db
self.db.add(StudyPlan(id=1, slug='prep-2025', name='PREP 2025', kind='set', is_published=1))
self.db.add(StudyPlan(id=2, slug='other-plan', name='Other', kind='set', is_published=1))
self.db.flush()
for position, title, qids in [(0, 'Block 1', [1, 2]), (1, 'Block 2', [5]), (2, 'Block 3', [])]:
self.db.add(StudyPlanBlock(plan_id=1, position=position, title=title, question_ids=qids))
self.db.add(StudyPlanBlock(id=90, plan_id=2, position=0, title='Elsewhere', question_ids=[6]))
self.db.add(Article(id=1, slug='asthma', title='Asthma', status='published',
sections=[], user_id=3))
self.db.add(Article(id=2, slug='draft-note', title='Draft note', status='draft',
sections=[], user_id=3))
self.db.commit()
self.blocks = {b.title: b.id for b in self.db.query(StudyPlanBlock).all()}
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def positions(self, plan_id=1):
return [(b.title, b.position) for b in self.db.query(StudyPlanBlock).filter_by(
plan_id=plan_id).order_by(StudyPlanBlock.position).all()]
# ── blocks ────────────────────────────────────────────────────────────────
def test_deleting_a_block_closes_the_gap_it_leaves(self):
response = self.client.delete(f"/study-plans/blocks/{self.blocks['Block 2']}")
self.assertEqual(response.status_code, 204, response.text)
self.db.expire_all()
# Positions are unique per plan, so a hole would collide with the next insert.
self.assertEqual(self.positions(), [('Block 1', 0), ('Block 3', 1)])
self.assertEqual(self.client.post('/study-plans/1/blocks',
json={'title': 'Block 4'}).status_code, 201)
def test_reordering_survives_the_unique_position_constraint(self):
order = [self.blocks['Block 3'], self.blocks['Block 1'], self.blocks['Block 2']]
response = self.client.post('/study-plans/1/blocks/order', json={'block_ids': order})
self.assertEqual(response.status_code, 200, response.text)
self.db.expire_all()
self.assertEqual(self.positions(), [('Block 3', 0), ('Block 1', 1), ('Block 2', 2)])
def test_a_partial_order_is_refused_rather_than_half_applied(self):
response = self.client.post('/study-plans/1/blocks/order',
json={'block_ids': [self.blocks['Block 1']]})
self.assertEqual(response.status_code, 400)
self.db.expire_all()
self.assertEqual(self.positions(), [('Block 1', 0), ('Block 2', 1), ('Block 3', 2)])
def test_questions_move_between_blocks_of_the_same_plan_only(self):
source, target = self.blocks['Block 1'], self.blocks['Block 2']
response = self.client.post(f'/study-plans/blocks/{source}/move',
json={'question_ids': [1], 'to_block_id': target})
self.assertEqual(response.status_code, 200, response.text)
self.db.expire_all()
self.assertEqual(self.db.get(StudyPlanBlock, source).question_ids, [2])
self.assertEqual(self.db.get(StudyPlanBlock, target).question_ids, [5, 1])
# A different plan is a different route through the bank.
self.assertEqual(self.client.post(f'/study-plans/blocks/{source}/move',
json={'question_ids': [2], 'to_block_id': 90}).status_code, 400)
def test_a_block_only_holds_questions_that_exist(self):
block = self.blocks['Block 3']
self.assertEqual(self.client.patch(f'/study-plans/blocks/{block}',
json={'question_ids': [1, 9999]}).status_code, 400)
self.db.expire_all()
self.assertEqual(self.db.get(StudyPlanBlock, block).question_ids, [])
# ── reading ───────────────────────────────────────────────────────────────
def test_reading_is_attached_once_and_appears_on_the_block(self):
block = self.blocks['Block 1']
link_id = self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).json()['link_id']
self.assertEqual(self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).status_code, 409)
plan = self.client.get('/study-plans/1').json()
first = next(b for b in plan['blocks'] if b['id'] == block)
self.assertEqual([a['title'] for a in first['articles']], ['Asthma'])
self.assertFalse(first['articles'][0]['read'])
self.assertEqual(first['articles'][0]['link_id'], link_id)
def test_marking_read_is_per_learner_and_reversible(self):
block = self.blocks['Block 1']
link_id = self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).json()['link_id']
self.bank.user = self.bank.owner
self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': True})
read_by_owner = next(a for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles'])
self.assertTrue(read_by_owner['read'])
# Another learner's progress is their own.
self.bank.user = self.bank.peer
self.assertFalse(next(a for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles'])['read'])
self.bank.user = self.bank.owner
self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': False})
self.assertFalse(next(a for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles'])['read'])
self.assertEqual(self.db.query(StudyPlanArticleRead).count(), 0)
def test_a_draft_article_is_not_offered_to_a_learner_as_a_dead_link(self):
block = self.blocks['Block 1']
self.client.post(f'/study-plans/blocks/{block}/articles', json={'article_id': 2})
self.bank.user = self.bank.owner
titles = [a['title'] for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles']]
self.assertEqual(titles, [])
self.bank.user = self.bank.mod
titles = [a['title'] for b in self.client.get('/study-plans/1').json()['blocks']
for a in b['articles']]
self.assertEqual(titles, ['Draft note'])
def test_detaching_reading_takes_the_progress_with_it(self):
block = self.blocks['Block 1']
link_id = self.client.post(f'/study-plans/blocks/{block}/articles',
json={'article_id': 1}).json()['link_id']
self.bank.user = self.bank.owner
self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': True})
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f'/study-plans/reading/{link_id}').status_code, 204)
self.assertEqual(self.db.query(StudyPlanBlockArticle).count(), 0)
self.assertEqual(self.db.query(StudyPlanArticleRead).count(), 0)
# ── plans and permissions ────────────────────────────────────────────────
def test_an_unpublished_plan_is_the_educators_alone(self):
self.client.patch('/study-plans/1', json={'is_published': False})
self.bank.user = self.bank.owner
self.assertEqual([p['id'] for p in self.client.get('/study-plans/').json()], [2])
self.assertEqual(self.client.get('/study-plans/1').status_code, 404)
self.bank.user = self.bank.mod
self.assertIn(1, [p['id'] for p in self.client.get('/study-plans/').json()])
self.assertEqual(self.client.get('/study-plans/1').status_code, 200)
def test_editing_is_moderator_only(self):
self.bank.user = self.bank.owner
block = self.blocks['Block 1']
for call in [
lambda: self.client.post('/study-plans/', json={'name': 'X', 'slug': 'x'}),
lambda: self.client.patch('/study-plans/1', json={'name': 'X'}),
lambda: self.client.delete('/study-plans/1'),
lambda: self.client.post('/study-plans/1/blocks', json={'title': 'B'}),
lambda: self.client.patch(f'/study-plans/blocks/{block}', json={'title': 'B'}),
lambda: self.client.delete(f'/study-plans/blocks/{block}'),
lambda: self.client.post(f'/study-plans/blocks/{block}/articles', json={'article_id': 1}),
]:
self.assertEqual(call().status_code, 403)
def test_a_slug_has_to_be_a_slug_and_has_to_be_free(self):
self.assertEqual(self.client.post('/study-plans/', json={
'name': 'New', 'slug': 'Not A Slug'}).status_code, 400)
self.assertEqual(self.client.post('/study-plans/', json={
'name': 'New', 'slug': 'prep-2025'}).status_code, 409)
self.assertEqual(self.client.post('/study-plans/', json={
'name': 'New', 'slug': 'prep-2026'}).status_code, 201)