"""Additional question category assignments: counts, filters, validation and migration.""" import io import os import unittest from datetime import datetime from pathlib import Path from unittest.mock import patch import test_quiz_builder as fixtures from alembic import command from alembic.config import Config from alembic.script import ScriptDirectory from app.models.question import Question from app.models.question_category import QuestionCategoryLink class MultiCategoryTests(unittest.TestCase): def setUp(self): self.bank = fixtures.BuilderTests() self.bank.setUp() self.client = self.bank.client def tearDown(self): self.bank.tearDown() def test_additional_categories_update_counts_filters_and_bank_rows(self): self.bank.user = self.bank.mod # Question management is moderator-only. question = self.bank.db.get(Question, 3) self.assertEqual(question.question_category_id, 3) response = self.client.patch('/questions/3', json={'additional_category_ids': [4, 2]}) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json()['question_category_id'], 3) self.bank.user = self.bank.owner # Viewing counts stays open to learners. cats = {cat['id']: cat['question_count'] for cat in self.client.get('/question-categories/').json()} self.assertEqual(cats[4], 1) # Was an empty category; now includes the linked question. self.assertEqual(cats[2], 4) # Questions 2, 4 and 5, plus the additional link on question 3. self.assertEqual(cats[1], 5) # Question 3 was already counted through its primary (descendant of 1). self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [4]}).json()['count'], 1) self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [2]}).json()['count'], 4) bank = self.client.get('/questions/bank', params={'category_ids': '4'}).json() self.assertEqual([row['id'] for row in bank['questions']], [3]) self.assertEqual(set(bank['questions'][0]['category_ids']), {2, 3, 4}) self.assertEqual(self.client.get('/questions/bank/ids', params={'category_ids': '4'}).json(), [3]) # Category quiz creation counts extra-linked questions too. created = self.client.post('/question-categories/4/create-quiz', params={'title': 'Linked quiz', 'mode': 'learning'}) self.assertEqual(created.status_code, 200, created.text) # A removed question is not reachable through its category either. self.bank.db.get(Question, 3).deleted_at = datetime(2026, 1, 1) self.bank.db.commit() self.assertEqual(self.client.get('/questions/builder/count', params={'category_ids': [4]}).json()['count'], 0) def test_additional_categories_replace_validate_and_ignore_primary(self): self.bank.user = self.bank.mod self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': [999]}).status_code, 400) self.assertEqual(self.client.patch('/questions/3', json={'question_category_id': 999}).status_code, 400) self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': [3, 4, 4]}).status_code, 200) links = {link.category_id for link in self.bank.db.query(QuestionCategoryLink).filter_by(question_id=3)} self.assertEqual(links, {4}) # Primary is ignored; duplicates collapsed. self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': []}).status_code, 200) self.assertEqual(self.bank.db.query(QuestionCategoryLink).filter_by(question_id=3).count(), 0) self.bank.user = self.bank.peer self.assertEqual(self.client.patch('/questions/3', json={'additional_category_ids': [4]}).status_code, 403) # Learners cannot edit. def test_offline_migration_creates_link_table(self): output = io.StringIO() config = Config(output_buffer=output) config.set_main_option('script_location', str(Path(__file__).resolve().parents[1] / 'alembic')) scripts = ScriptDirectory.from_config(config) self.assertEqual(len(scripts.get_heads()), 1) self.assertEqual(scripts.get_revision('h1c2d3e4f506').down_revision, 'g4b7e2f5a903') with patch.dict(os.environ, {'DATABASE_URL': 'postgresql://unused@127.0.0.1/offline_only'}): command.upgrade(config, 'g4b7e2f5a903:h1c2d3e4f506', sql=True) sql = output.getvalue() self.assertIn('CREATE TABLE IF NOT EXISTS question_category_links', sql) self.assertIn('UNIQUE (question_id, category_id)', sql) output.seek(0) output.truncate() command.downgrade(config, 'h1c2d3e4f506:g4b7e2f5a903', sql=True) self.assertIn('DROP TABLE IF EXISTS question_category_links', output.getvalue()) if __name__ == '__main__': unittest.main()