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

88 lines
4.2 KiB
Python

"""Turning a chat turn into a session.
The chat is for finding out what you do not know; the point of finding out is
to go and practise it. What is under test is which questions that lands on,
and that a chat cannot reach a question the learner could not otherwise see.
"""
import unittest
from datetime import datetime
from unittest.mock import patch
import test_quiz_builder as fixtures
from app.models.conversation import Conversation, ConversationMessage
from app.routers import ai_mode
class PracticeFromChatTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(ai_mode.router, prefix='/ai')
def tearDown(self):
self.bank.tearDown()
def thread(self, citations, asked='What causes stridor?'):
conversation = Conversation(user_id=1, title='Stridor')
self.db.add(conversation)
self.db.flush()
self.db.add(ConversationMessage(conversation_id=conversation.id, role='user',
content=asked, citations=[]))
answer = ConversationMessage(conversation_id=conversation.id, role='assistant',
content='Croup, mostly.', citations=citations)
self.db.add(answer)
self.db.commit()
return conversation.id, answer.id
def practise(self, conversation_id, **body):
with patch('app.services.search_service.hybrid_ids', return_value=([], {})):
return self.client.post(f'/ai/conversations/{conversation_id}/practice', json=body)
def test_a_cited_question_is_the_session(self):
cid, _ = self.thread([{'marker': '[[question:1]]', 'kind': 'question', 'id': 1,
'title': 'Question #1'}])
response = self.practise(cid)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()['count'], 1)
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
self.assertEqual([q['id'] for q in quiz['questions']], [1])
# Study mode, never exam: this is reading followed by practice.
self.assertEqual(quiz['mode'], 'learning')
def test_a_cited_article_brings_the_questions_filed_under_its_topic(self):
from app.models.article import Article
# The fixture files questions 1 and 2 under categories 1 and 2.
self.db.add(Article(id=5, title='Croup', slug='croup', content='',
status='published', user_id=3, category_id=2))
self.db.commit()
cid, _ = self.thread([{'marker': '[[article:5]]', 'kind': 'article', 'id': 5,
'title': 'Croup'}])
response = self.practise(cid)
self.assertEqual(response.status_code, 200, response.text)
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
self.assertEqual(sorted(q['id'] for q in quiz['questions']), [2, 4])
def test_a_chat_cannot_reach_a_question_the_learner_may_not_see(self):
# Question 4 has been removed from the bank. A citation could only name
# it by mistake; the bank's own rules answer anyway.
self.bank.db.get(fixtures.Question, 4).deleted_at = datetime(2026, 1, 1)
self.bank.db.commit()
cid, _ = self.thread([{'marker': '[[question:4]]', 'kind': 'question', 'id': 4,
'title': 'Question #4'}])
self.assertEqual(self.practise(cid).status_code, 404)
def test_nothing_to_practise_says_so(self):
cid, _ = self.thread([])
response = self.practise(cid)
self.assertEqual(response.status_code, 404)
self.assertIn('No questions', response.json()['detail'])
def test_one_turn_can_be_named_and_another_learner_cannot_ask(self):
cid, answer_id = self.thread([{'marker': '[[question:1]]', 'kind': 'question',
'id': 1, 'title': 'Question #1'}])
self.assertEqual(self.practise(cid, message_id=answer_id).status_code, 200)
self.assertEqual(self.practise(cid, message_id=99999).status_code, 404)
self.bank.user = self.bank.peer
self.assertEqual(self.practise(cid).status_code, 404)