pdf-quiz-generator/backend/tests/test_global_search.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

119 lines
5.8 KiB
Python

"""One query across every corpus, on disposable SQLite; no network or AI.
The point of these tests is the boundary, not the ranking: a search page that
had its own idea of who may see what is how private content leaks.
"""
import unittest
from datetime import datetime
import test_quiz_builder as fixtures
from app.models.article import Article, ArticleSectionIndex
from app.models.flashcard import Flashcard, FlashcardDeck
from app.models.question import Question
from app.routers import search
class GlobalSearchTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(search.router, prefix='/search')
self.db = self.bank.db
self.db.add_all([
Article(id=1, slug='febrile-seizures', title='Febrile seizures',
summary='A seizure with fever in a young child', content='Intro',
sections=[{"id": "a" * 32, "slug": "workup", "title": "Initial workup", "content": "Lumbar puncture"}],
status='published', user_id=3),
Article(id=2, slug='draft-topic', title='Febrile draft', summary='Unpublished febrile notes',
sections=[], status='draft', user_id=3),
])
self.db.add(ArticleSectionIndex(id=1, article_id=1, section_id='a' * 32,
title='Initial workup', content='Lumbar puncture in a febrile infant'))
self.db.add_all([
FlashcardDeck(id=1, title='Mine', user_id=1),
FlashcardDeck(id=2, title='Theirs', user_id=2),
])
self.db.flush()
self.db.add_all([
Flashcard(id=1, deck_id=1, front='Febrile seizure duration', back='Under 15 minutes'),
Flashcard(id=2, deck_id=2, front='Febrile seizure age', back='6 months to 5 years'),
])
self.db.query(Question).filter(Question.id == 1).update(
{"question_text": "A child with a febrile seizure lasting two minutes"})
self.db.query(Question).filter(Question.id == 3).update(
{"question_text": "A removed febrile seizure question",
"deleted_at": datetime(2026, 1, 1)})
self.db.commit()
def tearDown(self):
self.bank.tearDown()
def find(self, q='febrile', **params):
response = self.client.get('/search', params={'q': q, **params})
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def test_one_query_answers_from_every_corpus(self):
self.bank.user = self.bank.owner
data = self.find()
self.assertEqual(data['query'], 'febrile')
self.assertIn(1, [row['id'] for row in data['results']['article']])
self.assertIn(1, [row['id'] for row in data['results']['question']])
self.assertIn(1, [row['id'] for row in data['results']['flashcard']])
self.assertEqual(data['total'], sum(len(v) for v in data['results'].values()))
def test_a_section_hit_is_reported_under_its_article(self):
self.bank.user = self.bank.owner
article = next(row for row in self.find('lumbar')['results']['article'] if row['id'] == 1)
# Ten sections of one article are one result with ten places to start,
# not ten results burying everything else.
self.assertEqual([s['title'] for s in article['sections']], ['Initial workup'])
self.assertEqual(article['sections'][0]['section_id'], 'a' * 32)
def test_drafts_stay_invisible_to_everyone_but_their_educator(self):
self.bank.user = self.bank.owner
self.assertNotIn(2, [row['id'] for row in self.find()['results']['article']])
self.bank.user = self.bank.mod
self.assertIn(2, [row['id'] for row in self.find()['results']['article']])
def test_removed_questions_and_other_peoples_decks_are_not_searchable(self):
self.bank.user = self.bank.peer
data = self.find()
# Question 3 has been removed from the bank.
self.assertNotIn(3, [row['id'] for row in data['results']['question']])
# Deck 1 belongs to the owner; the peer's search must not reach into it.
self.assertNotIn(1, [row['id'] for row in data['results']['flashcard']])
def test_a_query_too_short_to_mean_anything_asks_nothing(self):
self.bank.user = self.bank.owner
data = self.find('f')
self.assertEqual(data['total'], 0)
self.assertEqual(data['results']['article'], [])
def test_kinds_narrows_the_answer(self):
self.bank.user = self.bank.owner
data = self.find(kinds='article')
self.assertEqual(set(data['results']), {'article'})
def test_a_snippet_shows_where_the_match_is_without_markup(self):
self.db.query(Article).filter(Article.id == 1).update(
{"summary": None, "content": "## Heading\n\nSee ![scan](/uploads/x.png) the **febrile** child."})
self.db.commit()
self.bank.user = self.bank.owner
snippet = next(row for row in self.find()['results']['article'] if row['id'] == 1)['snippet']
self.assertIn('febrile', snippet)
self.assertNotIn('##', snippet)
self.assertNotIn('/uploads/', snippet)
def test_suggestions_prefer_a_prefix_and_respect_drafts(self):
self.bank.user = self.bank.owner
titles = [s['title'] for s in self.client.get(
'/search/suggest', params={'q': 'febrile'}).json()['suggestions']]
self.assertEqual(titles, ['Febrile seizures'])
self.bank.user = self.bank.mod
titles = [s['title'] for s in self.client.get(
'/search/suggest', params={'q': 'febrile'}).json()['suggestions']]
self.assertIn('Febrile draft', titles)
self.assertEqual(self.client.get('/search/suggest', params={'q': 'f'}).json()['suggestions'], [])