fix: preserve saved quiz progress across resume failures

Block Start after failed resume, retry saved state, re-read reused-attempt progress, surface cache failures and prevent leaving on failed save. Handle nullable review settings and register lab model with Alembic. Verified 25 deployed-image backend tests, 53 frontend tests/build and PostgreSQL schema round-trip.
This commit is contained in:
Daniel 2026-09-07 03:34:55 +02:00
parent a3a6ef7995
commit c630b0270d
8 changed files with 166 additions and 18 deletions

View file

@ -8,6 +8,7 @@ from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite from app.models.favorite import Favorite
from app.models.user_note import UserNote from app.models.user_note import UserNote
from app.models.lab_reference import LabReference
__all__ = [ __all__ = [
"User", "User",
@ -21,4 +22,5 @@ __all__ = [
"AIModelConfig", "AIModelConfig",
"Favorite", "Favorite",
"UserNote", "UserNote",
"LabReference",
] ]

View file

@ -227,7 +227,7 @@ def list_attempts(
class ProgressSave(BaseModel): class ProgressSave(BaseModel):
quiz_id: int quiz_id: int
attempt_id: int attempt_id: int
answers: dict # {question_id: answer} answers: dict[int, str] # {question_id: answer}; reject malformed cached UI values
current_idx: int current_idx: int
mode: str mode: str
voice: str | None = None voice: str | None = None
@ -282,6 +282,7 @@ def save_progress(
})) }))
except Exception: except Exception:
logger.warning("Redis unavailable for progress save", exc_info=True) logger.warning("Redis unavailable for progress save", exc_info=True)
raise HTTPException(503, "Progress could not be saved. Keep this tab open and retry.")
return {"saved": True} return {"saved": True}
@ -373,7 +374,7 @@ def get_progress(
return saved return saved
except Exception: except Exception:
logger.warning("Redis unavailable for progress retrieval", exc_info=True) logger.warning("Redis unavailable for progress retrieval", exc_info=True)
return None raise HTTPException(503, "Saved progress is temporarily unavailable. Retry before starting.")
@router.delete("/progress/{attempt_id}", status_code=204) @router.delete("/progress/{attempt_id}", status_code=204)

View file

@ -60,7 +60,7 @@ class QuizResponse(BaseModel):
is_shared: int = 0 is_shared: int = 0
questions_per_attempt: int | None = None questions_per_attempt: int | None = None
course_id: int | None = None course_id: int | None = None
allow_review: int = 1 allow_review: int | None = 1
max_attempts: int | None = None max_attempts: int | None = None
attempt_mode: str | None = None attempt_mode: str | None = None

View file

@ -77,6 +77,40 @@ class StudyToolTests(unittest.TestCase):
self.assertEqual(empty['sample_size'], 0) self.assertEqual(empty['sample_size'], 0)
self.assertTrue(all(row['percentage'] == 0 for row in empty['options'])) self.assertTrue(all(row['percentage'] == 0 for row in empty['options']))
def test_nullable_review_setting_serializes_and_course_stays_exam_only(self):
self.bank.db.query(Quiz).filter(Quiz.id == 1).update({'allow_review': None})
self.bank.db.query(Quiz).filter(Quiz.id == 2).update({'allow_review': None, 'mode': 'learning', 'questions_count': 1})
self.bank.db.add(fixtures.CourseEnrollment(course_id=1, user_id=2))
self.bank.db.commit()
self.assertEqual(self.client.get('/quizzes/').status_code, 200)
response = self.client.get('/quizzes/1')
self.assertEqual(response.status_code, 200, response.text)
self.assertIsNone(response.json()['allow_review'])
self.bank.user = self.bank.peer
aid = self.start(2, 'study')
self.assertEqual(self.bank.db.get(QuizAttempt, aid).mode, 'exam')
response = self.client.get(f'/quizzes/2?attempt_id={aid}')
self.assertEqual(response.status_code, 200, response.text)
self.assertIsNone(response.json()['allow_review'])
self.assertNotIn('correct_answer', response.json()['questions'][0])
self.assertEqual(self.client.get(f'/quizzes/2?attempt_id={aid}&study=true').status_code, 403)
def test_progress_outage_returns_failure_not_empty_or_saved_success(self):
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
aid = self.start(quiz_id, 'study')
redis = Mock()
redis.from_url.return_value.get.side_effect = ConnectionError('Synthetic cache outage')
redis.from_url.return_value.setex.side_effect = ConnectionError('Synthetic cache outage')
with patch.dict(sys.modules, {'redis': redis}):
self.assertEqual(self.client.get(f'/attempts/progress?quiz_id={quiz_id}').status_code, 503)
response = self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': aid,
'answers': {'1': 'yes'}, 'current_idx': 0, 'mode': 'study'})
self.assertEqual(response.status_code, 503, response.text)
self.assertIsNone(self.bank.db.get(QuizAttempt, aid).completed_at)
malformed = self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': aid,
'answers': {'1': {'unexpected': 'object'}}, 'current_idx': 0, 'mode': 'study'})
self.assertEqual(malformed.status_code, 422)
def test_lab_reference_permissions_validation_and_publication(self): def test_lab_reference_permissions_validation_and_publication(self):
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units', payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference') age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')

View file

@ -1,6 +1,8 @@
"""Offline PostgreSQL DDL test for the study tools migration; no database access.""" """Offline PostgreSQL DDL test for the study tools migration; no database access."""
import io import io
import os import os
import subprocess
import sys
from pathlib import Path from pathlib import Path
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@ -13,6 +15,12 @@ from alembic.script import ScriptDirectory
class StudyToolsMigrationTests(unittest.TestCase): class StudyToolsMigrationTests(unittest.TestCase):
def test_alembic_model_imports_register_lab_table_in_fresh_process(self):
result = subprocess.run([sys.executable, '-c',
"import app.models; from app.database import Base; assert 'lab_reference_values' in Base.metadata.tables"],
capture_output=True, text=True, env={**os.environ, 'DATABASE_URL': 'sqlite:///:memory:'})
self.assertEqual(result.returncode, 0, result.stderr)
def test_explicit_offline_upgrade_and_downgrade(self): def test_explicit_offline_upgrade_and_downgrade(self):
output = io.StringIO() output = io.StringIO()
config = Config(output_buffer=output) config = Config(output_buffer=output)

View file

@ -47,7 +47,9 @@ Added a safe arithmetic calculator (no eval), keyboard shortcuts that do not int
Attempt mode is persisted server-side: a query flag cannot reveal active exam answers/statistics, and mismatched attempt/quiz/user IDs are rejected. Legacy attempts without stored mode resume as exam mode rather than exposing answers; their saved answers remain intact. The new `d94a26b8f302` migration must run before deployment. Attempt mode is persisted server-side: a query flag cannot reveal active exam answers/statistics, and mismatched attempt/quiz/user IDs are rejected. Legacy attempts without stored mode resume as exam mode rather than exposing answers; their saved answers remain intact. The new `d94a26b8f302` migration must run before deployment.
Combined verification after integrating login removal: **22 backend tests passed in the deployed image; 48 frontend tests and production build passed.** Actual PostgreSQL mode/lab migration verification and independent review are being completed separately. Combined verification after review fixes and login removal: **25 backend tests passed in the deployed image; 53 frontend tests and production build passed.** Actual PostgreSQL mode/lab upgrade, repeated upgrade, downgrade and re-upgrade passed; existing scores were preserved and the real lab ORM defaults/publication/FK were checked.
Independent review identified a failed-resume overwrite path, nullable review-setting serialization, and missing Alembic lab-model registration. Fixes add retry-only resume recovery, re-read saved progress for reused attempts, explicit cache failure responses, autosave warnings, blocked navigation when suspension cannot save, nullable/fail-closed course review handling, and model registration verified in a fresh process. New tests cover each case. Follow-up independent review is pending.
Browser checks used a loopback-only fixture with synthetic accounts/questions and in-memory data, not production authentication. Desktop layout, provisional/confirmed answers, live fixture response counts, calculator arithmetic, honest empty lab references, and review confirmation were checked. At 390×844 the document width was exactly 390 and no question, option, image or toolbar overflowed; a taller narrow frame captures the full page. A browser-profile reset interrupted the check; re-login restored the saved attempt successfully. Browser checks used a loopback-only fixture with synthetic accounts/questions and in-memory data, not production authentication. Desktop layout, provisional/confirmed answers, live fixture response counts, calculator arithmetic, honest empty lab references, and review confirmation were checked. At 390×844 the document width was exactly 390 and no question, option, image or toolbar overflowed; a taller narrow frame captures the full page. A browser-profile reset interrupted the check; re-login restored the saved attempt successfully.

View file

@ -282,7 +282,7 @@ function QuizCodeBadge({ code }) {
} }
function CourseQuizStart({ quiz, onStart }) { function CourseQuizStart({ quiz, onStart }) {
const mode = quiz.mode === 'timed' || quiz.allow_review === 0 ? 'exam' : 'study' const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study'
const [error, setError] = useState('') const [error, setError] = useState('')
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
const begin = async () => { const begin = async () => {
@ -453,6 +453,9 @@ export default function QuizPage() {
const [responseStats, setResponseStats] = useState(null) const [responseStats, setResponseStats] = useState(null)
const [statsError, setStatsError] = useState('') const [statsError, setStatsError] = useState('')
const [submitError, setSubmitError] = useState('') const [submitError, setSubmitError] = useState('')
const [resumeError, setResumeError] = useState('')
const [resumeRetry, setResumeRetry] = useState(0)
const [progressError, setProgressError] = useState('')
const timerRef = useRef(null) const timerRef = useRef(null)
const toastRef = useRef(null) const toastRef = useRef(null)
const hasStarted = useRef(false) const hasStarted = useRef(false)
@ -615,16 +618,18 @@ export default function QuizPage() {
// Warn before tab/window close when mid-quiz // Warn before tab/window close when mid-quiz
useEffect(() => { useEffect(() => {
if (!attemptId) return if (!attemptId) return
const msg = timeLeft !== null const msg = progressError || (timeLeft !== null
? 'Your quiz is timed. If you close this tab, the timer will continue and auto-submit when time expires. Progress is saved.' ? 'Your quiz is timed. Closing the tab leaves its timer running. Progress is saved while connected.'
: 'You have an in-progress quiz. Your progress is saved.' : 'You have an in-progress quiz. Progress is saved while connected.')
const handler = (e) => { e.preventDefault(); e.returnValue = msg } const handler = (e) => { e.preventDefault(); e.returnValue = msg }
window.addEventListener('beforeunload', handler) window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler)
}, [attemptId, timeLeft]) }, [attemptId, timeLeft, progressError])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
setLoading(true)
setResumeError('')
try { try {
const [quizRes, voicesRes, favoritesRes] = await Promise.all([ const [quizRes, voicesRes, favoritesRes] = await Promise.all([
api.get(`/quizzes/${id}`), api.get(`/quizzes/${id}`),
@ -646,16 +651,18 @@ export default function QuizPage() {
if (progressRes.data) { if (progressRes.data) {
await resumeQuiz(progressRes.data, voicesRes.data) await resumeQuiz(progressRes.data, voicesRes.data)
} }
} catch {} } catch {
setResumeError('Could not restore your saved attempt. Retry resume before starting; your saved answers have not been replaced.')
}
} catch { navigate('/') } } catch { navigate('/') }
finally { setLoading(false) } finally { setLoading(false) }
} }
load() load()
return () => clearInterval(timerRef.current) return () => clearInterval(timerRef.current)
}, [id]) }, [id, resumeRetry])
const startQuiz = async (mode, voice, timerMinutes = null) => { const startQuiz = async (mode, voice, timerMinutes = null) => {
if (hasStarted.current) return if (hasStarted.current || resumeError || loading) return
hasStarted.current = true hasStarted.current = true
setSelectedVoice(voice) setSelectedVoice(voice)
setStarting(true) setStarting(true)
@ -668,6 +675,12 @@ export default function QuizPage() {
mode = attemptRes.data.mode || mode mode = attemptRes.data.mode || mode
setAttemptId(attemptRes.data.id) setAttemptId(attemptRes.data.id)
const aid = attemptRes.data.id const aid = attemptRes.data.id
// A reused attempt may have newer progress from another tab/device.
const saved = await api.get('/attempts/progress', { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID } })
if (saved.data) {
await resumeQuiz(saved.data, voices)
return
}
// Fetch quiz with attempt_id for question pool filtering // Fetch quiz with attempt_id for question pool filtering
let quizData = quiz let quizData = quiz
@ -694,7 +707,7 @@ export default function QuizPage() {
time_left: mode === 'exam' && mins ? mins * 60 : null, time_left: mode === 'exam' && mins ? mins * 60 : null,
started_at: now, started_at: now,
total_time: mode === 'exam' && mins ? mins * 60 : null, total_time: mode === 'exam' && mins ? mins * 60 : null,
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {}) }, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.'))
} catch (err) { } catch (err) {
hasStarted.current = false hasStarted.current = false
throw err throw err
@ -729,7 +742,9 @@ const timerStarted = timeLeft !== null
started_at: startedAt, started_at: startedAt,
total_time: totalTime, total_time: totalTime,
...overrides, ...overrides,
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {}) }, { headers: { 'x-quiz-session': SESSION_ID } })
.then(() => setProgressError(''))
.catch(() => setProgressError('Autosave is unavailable. Keep this tab open and retry saving.'))
}, [id, answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime]) }, [id, answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
// Save progress to Redis (survives logout/browser change) // Save progress to Redis (survives logout/browser change)
@ -863,6 +878,7 @@ const timerStarted = timeLeft !== null
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null if (!quiz) return null
if (resumeError) return <div className="card" role="alert"><p>{resumeError}</p><button type="button" className="btn btn-primary" onClick={() => setResumeRetry(value => value + 1)}>Retry resume</button></div>
if (!quizMode) return ( if (!quizMode) return (
<div> <div>
@ -960,6 +976,7 @@ const timerStarted = timeLeft !== null
<button type="button" className="quiz-complete-confirm" disabled={submitting} onClick={() => { setShowReview(false); handleSubmit(false) }}>Complete test</button> <button type="button" className="quiz-complete-confirm" disabled={submitting} onClick={() => { setShowReview(false); handleSubmit(false) }}>Complete test</button>
</QuizDialog>} </QuizDialog>}
{submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>} {submitError && <div role="alert" className="quiz-submit-error">{submitError} <button type="button" disabled={submitting} onClick={() => handleSubmit(false)}>Retry submission</button></div>}
{progressError && <div role="alert" className="quiz-submit-error">{progressError} <button type="button" onClick={() => saveProgressNow()}>Retry saving</button></div>}
{/* In-app leave confirmation */} {/* In-app leave confirmation */}
{leaveTarget && ( {leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
@ -967,7 +984,7 @@ const timerStarted = timeLeft !== null
<div style={{ fontSize: '2rem', marginBottom: 12 }}></div> <div style={{ fontSize: '2rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2> <h2 style={{ marginBottom: 8 }}>Suspend quiz?</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}> <p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 20 }}>
Your progress is saved you can resume from where you left off. We will save your current answers before leaving, so you can resume from here.
{timeLeft !== null && ( {timeLeft !== null && (
<><br/><br/> <><br/><br/>
<strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/> <strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/>
@ -995,7 +1012,11 @@ const timerStarted = timeLeft !== null
total_time: totalTime, total_time: totalTime,
suspended: true, suspended: true,
}, { headers: { 'x-quiz-session': SESSION_ID } }) }, { headers: { 'x-quiz-session': SESSION_ID } })
} catch { } } catch {
setProgressError('Could not save before leaving. Keep this tab open and retry saving.')
setLeaveTarget(null)
return
}
} }
const target = leaveTarget const target = leaveTarget
setLeaveTarget(null) setLeaveTarget(null)

View file

@ -45,8 +45,8 @@ beforeEach(() => {
api.delete.mockResolvedValue({}) api.delete.mockResolvedValue({})
}) })
function mount() { function mount(entry = '/quizzes/10') {
render(<MemoryRouter initialEntries={['/quizzes/10']}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>) render(<MemoryRouter initialEntries={[entry]}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>)
} }
async function begin(study = true) { async function begin(study = true) {
mount() mount()
@ -55,6 +55,86 @@ async function begin(study = true) {
} }
describe('quiz player', () => { describe('quiz player', () => {
it('retries failed resume without offering Start or clearing saved answers', async () => {
mode = 'study'
const originalGet = api.get.getMockImplementation()
let failDetail = true
api.get.mockImplementation((url, ...args) => {
if (url === '/attempts/progress') return Promise.resolve({ data: { attempt_id: 50, mode: 'study', current_idx: 0, answers: { 1: 'First answer' } } })
if (url.includes('attempt_id=') && failDetail) { failDetail = false; return Promise.reject(new Error('Temporary detail failure')) }
return originalGet(url, ...args)
})
mount()
expect(await screen.findByRole('alert')).toHaveTextContent('Could not restore your saved attempt')
expect(screen.queryByRole('button', { name: /Study Mode/ })).not.toBeInTheDocument()
expect(api.post).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
fireEvent(window, new Event('pagehide'))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/attempts/progress', expect.objectContaining({ answers: { 1: 'First answer' } }), expect.any(Object)))
expect(api.post.mock.calls.some(([url]) => url.startsWith('/attempts/start'))).toBe(false)
expect(api.post.mock.calls.filter(([url]) => url === '/attempts/progress').every(([, body]) => body.answers[1] === 'First answer')).toBe(true)
})
it('does not treat a failed progress lookup as a new attempt', async () => {
const originalGet = api.get.getMockImplementation()
let failProgress = true
api.get.mockImplementation((url, ...args) => {
if (url === '/attempts/progress' && failProgress) { failProgress = false; return Promise.reject(new Error('Temporary progress failure')) }
return originalGet(url, ...args)
})
mount()
await screen.findByRole('button', { name: 'Retry resume' })
expect(screen.queryByRole('button', { name: /Study Mode/ })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
expect(await screen.findByRole('button', { name: /Study Mode/ })).toBeInTheDocument()
expect(api.post).not.toHaveBeenCalled()
})
it('restores progress when Start reuses an attempt updated by another tab', async () => {
const originalGet = api.get.getMockImplementation()
let lookups = 0
api.get.mockImplementation((url, ...args) => {
if (url === '/attempts/progress' && ++lookups > 1) return Promise.resolve({ data: { attempt_id: 50, mode: 'study', current_idx: 0, answers: { 1: 'First answer' } } })
return originalGet(url, ...args)
})
await begin()
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
fireEvent(window, new Event('pagehide'))
await waitFor(() => expect(api.post.mock.calls.some(([url]) => url === '/attempts/progress')).toBe(true))
expect(api.post.mock.calls.filter(([url]) => url === '/attempts/progress').every(([, body]) => body.answers[1] === 'First answer')).toBe(true)
})
it('uses exam mode for a course with a null review setting', async () => {
const originalGet = api.get.getMockImplementation()
api.get.mockImplementation(async (url, ...args) => {
const response = await originalGet(url, ...args)
return url.startsWith('/quizzes/10') ? { data: { ...response.data, mode: 'learning', allow_review: null, course_id: 1 } } : response
})
mount('/quizzes/10?return_to=%2Fcourses%2F1')
await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' }))
await screen.findByText('Full first clinical question.')
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
})
it('stays on the quiz when suspension cannot save and supports retry', async () => {
await begin(false)
api.post.mockClear()
const originalPost = api.post.getMockImplementation()
let failSaving = true
api.post.mockImplementation((url, ...args) => url === '/attempts/progress' && failSaving ? Promise.reject(new Error('Cache outage')) : originalPost(url, ...args))
fireEvent.keyDown(window, { key: '1' })
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open')
expect(screen.getByText('Full first clinical question.')).toBeInTheDocument()
failSaving = false
await userEvent.click(screen.getByRole('button', { name: 'Retry saving' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
expect(api.post.mock.calls.filter(([url]) => url === '/attempts/progress').every(([, body]) => body.answers[1] === 'First answer')).toBe(true)
})
it('keeps a study selection provisional until confirmation and shows genuine response data', async () => { it('keeps a study selection provisional until confirmation and shows genuine response data', async () => {
await begin() await begin()
expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/quizzes/create?category=11') expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/quizzes/create?category=11')