pdf-quiz-generator/backend/tests/test_category_migration.py
Daniel ebcc11ae12 feat: retire the tags, and stop settings from hiding from the page that
claims to hold them

The scaffolding is down. 203 subject, 2,275 disease and 4,281 keyword
tags, and 25,356 links, deleted — backed up first to a 1.9MB JSON of
replayable rows, because "we can always put it back" should be true
rather than said. The 16 system rows stay: categories point at them.

With them go the things that only existed to feed them — the
classify_questions task, its snapshot helpers, POST /tags/classify and
its status poll — and the three Taxonomy tabs that would now always read
zero. A tab showing 0 forever teaches people the page is broken.

The organ-system filter in the session builder moved onto categories with
the rest, including everything beneath a matched topic, so it groups the
way the analysis does.

Registration: `settings:registration_enabled` was set to false, and there
was no switch anywhere on the site to set it back. The API had always
accepted it; the Site policy page had never shown it. So the site could
be closed to new members with the admin looking at three switches, all
correct, and no way to see the one that was actually refusing them. It is
now the first switch on that page, and says plainly that the ones below
it have nothing to act on while it is off. The SSO-only flag was hidden
the same way and is shown when SSO is configured.

Deleting a topic no longer silently unfiles its questions. It asks where
they go, and says how many are waiting, unless the topic is empty — the
same rule promotion now follows. Its extra category links move too,
minus any that would duplicate a pair the destination already has.

Back links: Trash, Extraction jobs, Taxonomy and the Handbook had none at
all, and Access pointed at the wrong section. They are one component now,
each returning one step to the section it was opened from. Editorial has
its own entry in the section bar, so its Tools card is gone rather than
being a second door to the same room.

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

93 lines
4.3 KiB
Python

"""Exercise Alembic's real offline path; never connect to an external database."""
import io
import os
from pathlib import Path
import unittest
from unittest.mock import patch
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import test_quiz_builder as fixtures
from app.database import Base # Initialize the app engine with disposable SQLite before offline URL override.
from app.models.question import Question
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
class CategoryMigrationTests(unittest.TestCase):
def test_graph_upgrade_and_downgrade(self):
backend = Path(__file__).resolve().parents[1]
output = io.StringIO()
config = Config(output_buffer=output)
config.set_main_option("script_location", str(backend / "alembic"))
scripts = ScriptDirectory.from_config(config)
self.assertEqual(len(scripts.get_heads()), 1)
self.assertEqual(scripts.get_revision("c82d19e4a601").down_revision, "5f8c1c2a9d40")
list(scripts.walk_revisions())
# sql=True uses no engine/connection; the URL selects PostgreSQL DDL only.
with patch.dict(os.environ, {"DATABASE_URL": "postgresql://unused@127.0.0.1/offline_only"}):
command.upgrade(config, "5f8c1c2a9d40:c82d19e4a601", sql=True)
sql = output.getvalue()
self.assertIn("ADD COLUMN IF NOT EXISTS parent_id INTEGER", sql)
self.assertIn("WHERE conrelid = 'question_categories'::regclass", sql)
self.assertIn("FOREIGN KEY (parent_id) REFERENCES question_categories(id) ON DELETE RESTRICT", sql)
self.assertNotIn("UPDATE question_categories", sql)
output.seek(0)
output.truncate()
command.downgrade(config, "c82d19e4a601:5f8c1c2a9d40", sql=True)
self.assertIn("DROP CONSTRAINT IF EXISTS fk_question_categories_parent", output.getvalue())
self.assertIn("DROP COLUMN IF EXISTS parent_id", output.getvalue())
if __name__ == "__main__":
unittest.main()
class DeletingATopicTests(unittest.TestCase):
"""Where the questions go has to be said.
Deleting a topic used to leave them filed nowhere — no discipline, no
organ system, no relevance, no row on any tab of the analysis. In the bank
and invisible to every page that counts them.
"""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def test_a_topic_holding_questions_will_not_go_quietly(self):
# Category 3 holds question 3 in the fixture bank.
response = self.client.delete('/question-categories/3')
self.assertEqual(response.status_code, 400, response.text)
self.assertIn('Choose a topic to move them to', response.json()['detail'])
self.assertIsNotNone(self.db.get(Question, 3).question_category_id)
def test_naming_a_destination_moves_them_and_deletes_the_topic(self):
from app.models.question_category import QuestionCategory
response = self.client.delete('/question-categories/3', params={'move_to': 2})
self.assertEqual(response.status_code, 204, response.text)
self.assertEqual(self.db.get(Question, 3).question_category_id, 2)
self.assertIsNone(self.db.get(QuestionCategory, 3))
def test_an_empty_topic_goes_without_being_asked(self):
from app.models.question_category import QuestionCategory
# Category 4 holds nothing.
self.assertEqual(self.client.delete('/question-categories/4').status_code, 204)
self.assertIsNone(self.db.get(QuestionCategory, 4))
def test_an_extra_link_moves_too_and_does_not_duplicate(self):
from app.models.question_category import QuestionCategoryLink
# Question 1 is filed under category 1 and also linked to 3.
self.db.add(QuestionCategoryLink(question_id=1, category_id=3))
self.db.add(QuestionCategoryLink(question_id=1, category_id=2))
self.db.commit()
self.assertEqual(
self.client.delete('/question-categories/3', params={'move_to': 2}).status_code, 204)
pairs = self.db.query(QuestionCategoryLink).filter_by(question_id=1, category_id=2).count()
self.assertEqual(pairs, 1)