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; case variants collapse too. 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.bank.user = self.bank.mod self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Educators manage questions. self.bank.user = self.bank.owner 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) second = self.client.post('/study-tools/lab-values', json={**payload, 'age_group': 'z', 'article_id': None, 'article_section_id': None}) self.assertEqual(second.status_code, 201, second.text) # Same-name rows keep insertion (logical age) order, not alphabetical age order. names_in_order = [r['name'] + ':' + r['age_group'] for r in self.client.get('/study-tools/lab-values').json() if r['name'] == 'Deep linked'] self.assertEqual(names_in_order, ['Deep linked:a', 'Deep linked:z']) 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()