pdf-quiz-generator/backend/tests/test_study_tools.py
Daniel f5a084e49a feat: Orthobullets-style lab panel with source deep links and card links
Lab references deep-link to article sections or external sources, show linked cards with study links, and educators can attach cards and article targets. Grouped panel layout. Migration i2d3e4f5a607. 57 backend and 93 frontend tests pass.
2026-09-08 16:20:55 +02:00

234 lines
15 KiB
Python

import json
import sys
import unittest
from unittest.mock import Mock, patch
import test_quiz_builder as fixtures
from app.models.attempt import QuizAttempt
from app.models.quiz import Quiz
from app.routers import study_tools
class StudyToolTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(study_tools.router, prefix='/study-tools')
def tearDown(self):
self.bank.tearDown()
def start(self, quiz_id, mode):
response = self.client.post(f'/attempts/start?quiz_id={quiz_id}&mode={mode}&fresh=true')
self.assertEqual(response.status_code, 200, response.text)
return response.json()['id']
def test_attempt_mode_controls_answers_and_stats_not_query_flags(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], mode='learning').json()['id']
initial = self.client.get(f'/quizzes/{quiz_id}').json()
self.assertNotIn('correct_answer', initial['questions'][0])
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?study=true').status_code, 403)
exam = self.start(quiz_id, 'exam')
data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}').json()
self.assertEqual(data['attempt_mode'], 'exam')
self.assertNotIn('correct_answer', data['questions'][0])
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}&study=true').status_code, 403)
stats_url = f'/study-tools/attempts/{exam}/questions/1/responses'
self.assertEqual(self.client.get(stats_url).status_code, 403)
redis = Mock()
with patch.dict(sys.modules, {'redis': redis}):
self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': exam, 'answers': {}, 'current_idx': 0, 'mode': 'study'})
stored = json.loads(redis.from_url.return_value.setex.call_args_list[-1].args[2])
self.assertEqual(stored['mode'], 'exam')
study = self.start(quiz_id, 'study')
data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').json()
self.assertEqual(data['attempt_mode'], 'study')
self.assertEqual(data['questions'][0]['correct_answer'], 'yes')
self.assertTrue(data['questions'][0]['category_breadcrumbs'])
self.assertEqual(self.client.get('/quizzes/1', params={'attempt_id': study}).status_code, 404)
self.bank.user = self.bank.peer
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').status_code, 404)
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
def test_real_response_counts_exclude_skips_expiry_course_and_private_attempts(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
study = self.start(quiz_id, 'study')
self.bank.answer(1, True)
self.bank.answer(1, True, day=1)
self.bank.answer(1, False, day=2)
self.bank.answer(1, True, expired=1)
self.bank.answer(1, True, quiz_id=2)
self.bank.answer(1, True, completed=False)
other_private = Quiz(title='Private peer test', user_id=2, is_shared=0, is_published=0)
self.bank.db.add(other_private)
self.bank.db.commit()
self.bank.answer(1, True, quiz_id=other_private.id)
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses')
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()['sample_size'], 3)
self.assertEqual([row['count'] for row in response.json()['options']], [2, 1])
self.assertEqual([row['percentage'] for row in response.json()['options']], [66.7, 33.3])
attempt = self.bank.db.get(QuizAttempt, study)
attempt.selected_question_ids = [2]
self.bank.db.commit()
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
empty = self.client.get(f'/study-tools/attempts/{study}/questions/2/responses').json()
self.assertEqual(empty['sample_size'], 0)
self.assertTrue(all(row['percentage'] == 0 for row in empty['options']))
def test_nullable_review_setting_serializes_and_course_stays_exam_only(self):
self.bank.db.query(Quiz).filter(Quiz.id == 1).update({'allow_review': None})
self.bank.db.query(Quiz).filter(Quiz.id == 2).update({'allow_review': None, 'mode': 'learning', 'questions_count': 1})
self.bank.db.add(fixtures.CourseEnrollment(course_id=1, user_id=2))
self.bank.db.commit()
self.assertEqual(self.client.get('/quizzes/').status_code, 200)
response = self.client.get('/quizzes/1')
self.assertEqual(response.status_code, 200, response.text)
self.assertIsNone(response.json()['allow_review'])
self.bank.user = self.bank.peer
aid = self.start(2, 'study')
self.assertEqual(self.bank.db.get(QuizAttempt, aid).mode, 'exam')
response = self.client.get(f'/quizzes/2?attempt_id={aid}')
self.assertEqual(response.status_code, 200, response.text)
self.assertIsNone(response.json()['allow_review'])
self.assertNotIn('correct_answer', response.json()['questions'][0])
self.assertEqual(self.client.get(f'/quizzes/2?attempt_id={aid}&study=true').status_code, 403)
def test_progress_outage_returns_failure_not_empty_or_saved_success(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
aid = self.start(quiz_id, 'study')
redis = Mock()
redis.from_url.return_value.get.side_effect = ConnectionError('Synthetic cache outage')
redis.from_url.return_value.setex.side_effect = ConnectionError('Synthetic cache outage')
with patch.dict(sys.modules, {'redis': redis}):
self.assertEqual(self.client.get(f'/attempts/progress?quiz_id={quiz_id}').status_code, 503)
response = self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': aid,
'answers': {'1': 'yes'}, 'current_idx': 0, 'mode': 'study'})
self.assertEqual(response.status_code, 503, response.text)
self.assertIsNone(self.bank.db.get(QuizAttempt, aid).completed_at)
malformed = self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': aid,
'answers': {'1': {'unexpected': 'object'}}, 'current_idx': 0, 'mode': 'study'})
self.assertEqual(malformed.status_code, 422)
def test_statistics_match_insensitively_dedupe_and_exclude_obsolete(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
study = self.start(quiz_id, 'study')
# Case/whitespace variants of the same option match one bucket.
self.bank.answer(1, True)
self.bank.db.query(fixtures.AttemptAnswer).filter_by(is_correct=True).update({'user_answer': ' YES '})
self.bank.db.commit()
# An answer that no longer matches any current option is excluded from the sample.
self.bank.answer(1, False)
self.bank.db.query(fixtures.AttemptAnswer).filter_by(is_correct=False).update({'user_answer': 'Obsolete option'})
self.bank.db.commit()
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').json()
self.assertEqual(response['sample_size'], 1)
self.assertEqual([row['count'] for row in response['options']], [1, 0])
self.assertEqual(response['options'][0]['percentage'], 100.0)
# Duplicate option strings must not double-count.
question = self.bank.db.get(fixtures.Question, 1)
question.options = ['yes', 'yes']
self.bank.db.commit()
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').json()
self.assertEqual(response['sample_size'], 1)
self.assertEqual(len(response['options']), 1)
def test_own_private_quiz_attempt_counts_only_for_its_owner(self):
private = self.client.post('/questions/from-bank', json={'title': 'Private set', 'question_ids': [1]}).json()['id']
study = self.start(private, 'study')
self.bank.answer(1, True, quiz_id=private)
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses')
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()['sample_size'], 1)
self.bank.user = self.bank.peer
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
other_quiz = self.client.post('/questions/from-bank', json={'title': 'Shared set', 'question_ids': [1], 'is_shared': True}).json()['id']
peer_study = self.start(other_quiz, 'study')
self.bank.answer(1, True, quiz_id=private)
response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json()
self.assertEqual(response['sample_size'], 0)
def test_performance_by_category_expands_links_and_excludes_irrelevant(self):
quiz = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
self.bank.answer(1, True, quiz_id=quiz)
self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded.
quiz2 = self.bank.generate(is_shared=True, category_ids=[2], count=1).json()['id']
self.bank.answer(2, False, quiz_id=quiz2)
self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Owner edits own question.
quiz3 = self.bank.generate(category_ids=[3], count=1).json()['id'] # Private owner test: own attempts still count.
self.bank.answer(3, True, quiz_id=quiz3)
self.bank.answer(5, True, quiz_id=2) # Course quiz excluded.
data = self.client.get('/study-tools/performance-by-category').json()
by_id = {row['category_id']: row for row in data['categories']}
self.assertEqual([by_id[1]['answered'], by_id[1]['correct'], by_id[1]['accuracy']], [1, 1, 100.0])
self.assertEqual([by_id[2]['answered'], by_id[2]['correct'], by_id[2]['accuracy']], [2, 1, 50.0])
self.assertEqual([by_id[3]['answered'], by_id[3]['correct']], [1, 1])
self.assertEqual(data['total_answered'], 4)
self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first.
def test_lab_article_deep_links_and_card_links(self):
from app.models.article import Article
from app.models.flashcard import Flashcard, FlashcardDeck
article = Article(slug='lab-source', title='Lab source article', content='Intro',
sections=[{'id': 'd' * 32, 'slug': 'ranges', 'title': 'Ranges', 'content': 'Body'}],
user_id=3, status='published')
deck = FlashcardDeck(user_id=3, title='Sodium cards', is_shared=0)
self.bank.db.add_all([article, deck])
self.bank.db.flush()
card = Flashcard(deck_id=deck.id, front='Sodium card front', back='back')
self.bank.db.add(card)
self.bank.db.commit()
self.bank.user = self.bank.mod
payload = dict(name='Deep linked', group='Blood', reference_range='1-2', units='u',
age_group='a', specimen='s', source='src', article_id=article.id,
article_section_id='d' * 32, is_published=True)
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': 999}).status_code, 400)
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_section_id': 'bad'}).status_code, 400)
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': None, 'article_section_id': 'd' * 32}).status_code, 400)
created = self.client.post('/study-tools/lab-values', json=payload)
self.assertEqual(created.status_code, 201, created.text)
entry_id = created.json()['id']
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/999').status_code, 404)
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], True)
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], False)
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)
self.assertEqual(row['article_title'], 'Lab source article')
self.assertEqual(row['article_section_title'], 'Ranges')
self.assertEqual([c['front'] for c in row['cards']], ['Sodium card front'])
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 204)
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)
self.assertEqual(row['cards'], [])
self.bank.user = self.bank.owner
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 403)
def test_lab_reference_permissions_validation_and_publication(self):
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')
self.assertEqual(self.client.post('/study-tools/lab-values', json=payload).status_code, 403)
self.assertEqual(self.client.get('/study-tools/lab-values?include_drafts=true').status_code, 403)
self.bank.user = self.bank.mod
response = self.client.post('/study-tools/lab-values', json=payload)
self.assertEqual(response.status_code, 201, response.text)
entry_id = response.json()['id']
self.assertFalse(response.json()['is_published'])
self.assertEqual(self.client.get('/study-tools/lab-values').json(), [])
self.assertEqual(len(self.client.get('/study-tools/lab-values?include_drafts=true').json()), 1)
for bad in ({'source': ' '}, {'source_url': 'javascript:alert(1)'}, {'source_url': 'https://example.com/' + 'a' * 2000}, {'units': ''}):
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, **bad}).status_code, 422)
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, 'is_published': True}).status_code, 200)
self.bank.user = self.bank.peer
rows = self.client.get('/study-tools/lab-values').json()
self.assertEqual(rows[0]['source'], payload['source'])
self.assertEqual(rows[0]['age_group'], payload['age_group'])
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json=payload).status_code, 403)
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 403)
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 204)
self.assertEqual(self.client.get('/study-tools/lab-values').json(), [])
if __name__ == '__main__':
unittest.main()