"""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)