pdf-quiz-generator/backend/tests/test_multi_category.py
Daniel 5d59e00144 refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.

So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.

The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:42:51 +02:00

83 lines
4.9 KiB
Python

"""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], 3) # Questions 2 and 4, plus the additional link on question 3.
self.assertEqual(cats[1], 4) # 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'], 3)
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()