Skip to content

Commit 1ad1d7f

Browse files
authored
(SP: 5) [Quiz] Redis caching + guest session fix + cleanup (#263)
* feat(quiz-ui): quiz UI polish - tabs, category accents, color scheme (issues #181, #193, #194) - Refactor QaTabButton to shared CategoryTabButton component - Add category accent colors to QuizCard, buttons, progress indicators - Standardize colors with CSS variables, traffic light timer - Add DynamicGridBackground to quizzes list page - Border-only answer feedback, semi-transparent progress styles * docs: update .gitignore * fix(quiz): align disqualification threshold with warning banner Changed violationsCount > 3 to >= 3 in QuizResult points block to match the warning banner threshold at line 124. * feat(quiz-testing): add quiz unit tests - Configure Vitest for quiz module - Add test factories and setup utilities - Add quiz-crypto tests (13 tests) - Add quiz-session tests (12 tests) * test(quiz): add integration tests for verify-answer API and useAntiCheat hook (#199) - verify-answer.test.ts: 8 tests for API endpoint - Correct/wrong answer verification - Validation errors (missing fields, tampered data) - Security: rejects modified encrypted answers - quiz-anticheat.test.ts: 10 tests for useAntiCheat hook - Detects copy, paste, context-menu, tab-switch events - Respects isActive flag - Reset and cleanup functionality Total quiz tests: 52 (9 setup + 25 unit + 18 integration) * test(quiz): expand test coverage to 90%+ with hooks, API routes, and UI flow Add 28 new tests covering: - useQuizSession hook (6 tests) - useQuizGuards hook (8 tests) - guest-quiz storage (5 tests) - guest-result API route (5 tests) - quiz-slug API route (3 tests) - QuizContainer UI flow (1 test) Coverage: 35% -> 90.94% (quiz scope) Tests: 52 -> 80 * chore: remove coverage-quiz from git, add to .gitignore * chore: add coverage-quiz to .gitignore, fix quiz guards test * fix(a11y): improve quiz accessibility and i18n compliance * fix(sl/feat/quiz): replace with correct name for react icon * feat(quiz): implement Redis caching + session fixes + cleanup Closes #260, #261, #262 - Add quiz-answers-redis.ts with getOrCreateQuizAnswersCache() - Cache correct answers per quiz (12h TTL) - Replace AES-256-GCM decryption with O(1) Redis lookup - Add initializeQuizCache server action - Update verify-answer route to use Redis - Allow restoring 'completed' sessions (not just 'in_progress') - Only clear session for authenticated users after submit - Guest result screen now survives language switch - Delete PendingResultHandler.tsx (never executes) - Delete start-session/route.ts (broken import, unused) - Delete quiz-crypto.ts (AES replaced by Redis) - Delete quiz-crypto.test.ts (tests dead code) - Rewrite verify-answer.test.ts for Redis API (8 tests) - Fix quiz-session.test.ts for completed session restore * chore(quiz): delete unused start-session route Part of #262 cleanup - route had broken import after Redis migration. * git commit -m "fix(quiz): add NaN seed validation and cache/DB fallback - Validate seed param to prevent NaN breaking question shuffle - Add cache recovery in verify-answer when Redis cache expires - Add DB fallback in getCorrectAnswer when Redis unavailable
1 parent 4694478 commit 1ad1d7f

87 files changed

Lines changed: 452 additions & 569 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/actions/quiz.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use server';
22

3-
import { and,eq, inArray } from 'drizzle-orm';
3+
import { eq, inArray } from 'drizzle-orm';
44

55
import { db } from '@/db';
66
import { awardQuizPoints, calculateQuizPoints } from '@/db/queries/points';
@@ -255,3 +255,22 @@ export async function submitQuizAttempt(
255255
};
256256
}
257257
}
258+
259+
export async function initializeQuizCache(
260+
quizId: string
261+
): Promise<{ success: boolean; error?: string }> {
262+
try {
263+
const { getOrCreateQuizAnswersCache } =
264+
await import('@/lib/quiz/quiz-answers-redis');
265+
const success = await getOrCreateQuizAnswersCache(quizId);
266+
267+
if (!success) {
268+
return { success: false, error: 'Quiz not found' };
269+
}
270+
271+
return { success: true };
272+
} catch (error) {
273+
console.error('Failed to initialize quiz cache:', error);
274+
return { success: false, error: 'Internal server error' };
275+
}
276+
}

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { StatsCard } from '@/components/dashboard/StatsCard';
77
import { getUserQuizStats } from '@/db/queries/quiz';
88
import { getUserProfile } from '@/db/queries/users';
99
import { redirect } from '@/i18n/routing';
10-
import { Link } from '@/i18n/routing';
1110
import { getCurrentUser } from '@/lib/auth';
1211

1312
export async function generateMetadata({
@@ -85,17 +84,17 @@ export default async function DashboardPage({
8584
className="pointer-events-none absolute inset-0 -z-10"
8685
aria-hidden="true"
8786
>
88-
<div className="absolute inset-0 bg-gradient-to-b from-sky-50 via-white to-rose-50 dark:from-slate-950 dark:via-slate-950 dark:to-black" />
89-
<div className="absolute top-0 left-1/4 h-96 w-[36rem] -translate-x-1/2 rounded-full bg-sky-300/20 blur-3xl dark:bg-sky-500/10" />
90-
<div className="absolute right-0 bottom-0 h-[26rem] w-[26rem] rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-500/10" />
91-
<div className="absolute bottom-10 left-10 h-[20rem] w-[20rem] rounded-full bg-pink-300/20 blur-3xl dark:bg-fuchsia-500/10" />
87+
<div className="absolute inset-0 bg-linear-to-b from-sky-50 via-white to-rose-50 dark:from-slate-950 dark:via-slate-950 dark:to-black" />
88+
<div className="absolute top-0 left-1/4 h-96 w-xl -translate-x-1/2 rounded-full bg-sky-300/20 blur-3xl dark:bg-sky-500/10" />
89+
<div className="absolute right-0 bottom-0 h-104 w-104 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-500/10" />
90+
<div className="absolute bottom-10 left-10 h-80 w-[20rem] rounded-full bg-pink-300/20 blur-3xl dark:bg-fuchsia-500/10" />
9291
</div>
9392

9493
<div className="relative z-10 mx-auto max-w-5xl px-6 py-12">
9594
<header className="mb-12 flex flex-col justify-between gap-6 md:flex-row md:items-center">
9695
<div>
9796
<h1 className="text-4xl font-black tracking-tight drop-shadow-sm md:text-5xl">
98-
<span className="bg-gradient-to-r from-sky-400 via-violet-400 to-pink-400 bg-clip-text text-transparent dark:from-sky-400 dark:via-indigo-400 dark:to-fuchsia-500">
97+
<span className="bg-linear-to-r from-sky-400 via-violet-400 to-pink-400 bg-clip-text text-transparent dark:from-sky-400 dark:via-indigo-400 dark:to-fuchsia-500">
9998
{t('title')}
10099
</span>
101100
</h1>

frontend/app/[locale]/quiz/[slug]/page.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import { notFound } from 'next/navigation';
1+
import { notFound, redirect } from 'next/navigation';
22
import { getTranslations } from 'next-intl/server';
33

4-
import { PendingResultHandler } from '@/components/quiz/PendingResultHandler';
54
import { QuizContainer } from '@/components/quiz/QuizContainer';
65
import { stripCorrectAnswers } from '@/db/queries/quiz';
76
import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz';
87
import { getCurrentUser } from '@/lib/auth';
9-
import { createEncryptedAnswersBlob } from '@/lib/quiz/quiz-crypto';
108

119
interface QuizPageProps {
1210
params: Promise<{ locale: string; slug: string }>;
@@ -29,10 +27,19 @@ export default async function QuizPage({
2927
notFound();
3028
}
3129

32-
const seed = seedParam ? parseInt(seedParam, 10) : Date.now();
30+
if (!seedParam) {
31+
// eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
32+
redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
33+
}
34+
35+
const seed = Number.parseInt(seedParam, 10);
36+
if (Number.isNaN(seed)) {
37+
// eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
38+
redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
39+
}
40+
3341
const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);
3442

35-
const encryptedAnswers = createEncryptedAnswersBlob(questions);
3643
const clientQuestions = stripCorrectAnswers(questions);
3744

3845
if (!questions.length) {
@@ -73,13 +80,11 @@ export default async function QuizPage({
7380
quizSlug={slug}
7481
quizId={quiz.id}
7582
questions={clientQuestions}
76-
encryptedAnswers={encryptedAnswers}
7783
userId={user?.id ?? null}
7884
timeLimitSeconds={quiz.timeLimitSeconds ?? questions.length * 30}
7985
seed={seed}
8086
categorySlug={quiz.categorySlug}
8187
/>
82-
{user && <PendingResultHandler userId={user.id} />}
8388
</div>
8489
</div>
8590
);

frontend/app/api/auth/github/callback/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
33

44
import { db } from '@/db';
55
import { users } from '@/db/schema/users';
6-
import { setAuthCookie,signAuthToken } from '@/lib/auth';
6+
import { setAuthCookie, signAuthToken } from '@/lib/auth';
77
import { consumeOAuthState } from '@/lib/auth/oauth-state';
88
import { authEnv } from '@/lib/env/auth';
99

frontend/app/api/auth/google/callback/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
33

44
import { db } from '@/db';
55
import { users } from '@/db/schema/users';
6-
import { setAuthCookie,signAuthToken } from '@/lib/auth';
6+
import { setAuthCookie, signAuthToken } from '@/lib/auth';
77
import { consumeOAuthState } from '@/lib/auth/oauth-state';
88
import { authEnv } from '@/lib/env/auth';
99

frontend/app/api/auth/login/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { z } from 'zod';
66

77
import { db } from '@/db';
88
import { users } from '@/db/schema/users';
9-
import { setAuthCookie,signAuthToken } from '@/lib/auth';
9+
import { setAuthCookie, signAuthToken } from '@/lib/auth';
1010

1111
export const runtime = 'nodejs';
1212

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { getRedisClient } from '@/lib/redis';
4+
5+
export async function DELETE() {
6+
if (process.env.NODE_ENV !== 'development') {
7+
return NextResponse.json({ error: 'Dev only' }, { status: 403 });
8+
}
9+
10+
const redis = getRedisClient();
11+
if (!redis) {
12+
return NextResponse.json({ error: 'No Redis client' }, { status: 500 });
13+
}
14+
15+
const keys = await redis.keys('quiz:answers:*');
16+
17+
if (keys.length > 0) {
18+
await redis.del(...keys);
19+
}
20+
21+
return NextResponse.json({ deleted: keys.length });
22+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { getRedisClient } from '@/lib/redis';
4+
5+
export async function GET() {
6+
if (process.env.NODE_ENV === 'production') {
7+
return NextResponse.json({ error: 'Not available' }, { status: 403 });
8+
}
9+
10+
const redis = getRedisClient();
11+
if (!redis) {
12+
return NextResponse.json({ error: 'Redis not configured' });
13+
}
14+
15+
const keys = await redis.keys('quiz:answers:*');
16+
const data: Record<string, unknown> = {};
17+
18+
for (const key of keys) {
19+
data[key] = await redis.get(key);
20+
}
21+
22+
return NextResponse.json({
23+
count: keys.length,
24+
keys,
25+
data,
26+
});
27+
}

frontend/app/api/questions/[category]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, eq, ilike,sql } from 'drizzle-orm';
1+
import { and, eq, ilike, sql } from 'drizzle-orm';
22
import { NextResponse } from 'next/server';
33

44
import { db } from '@/db';

frontend/app/api/quiz/verify-answer/route.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,48 @@
1-
import { NextRequest, NextResponse } from 'next/server';
1+
import { NextResponse } from 'next/server';
22

3-
import { decryptAnswers } from '@/lib/quiz/quiz-crypto';
3+
import { getCorrectAnswer, getOrCreateQuizAnswersCache } from '@/lib/quiz/quiz-answers-redis';
44

5-
interface VerifyRequest {
6-
questionId: string;
7-
answerId: string;
8-
encryptedAnswers: string;
9-
}
5+
export const runtime = 'nodejs';
106

11-
export async function POST(request: NextRequest) {
7+
export async function POST(req: Request) {
128
try {
13-
const body: VerifyRequest = await request.json();
14-
const { questionId, answerId, encryptedAnswers } = body;
9+
const body = await req.json().catch(() => null);
1510

16-
if (!questionId || !answerId || !encryptedAnswers) {
11+
if (!body?.quizId || !body?.questionId || !body?.selectedAnswerId) {
1712
return NextResponse.json(
18-
{ error: 'Missing required fields' },
13+
{ success: false, error: 'Missing required fields' },
1914
{ status: 400 }
2015
);
2116
}
2217

23-
const correctAnswersMap = decryptAnswers(encryptedAnswers);
18+
const { quizId, questionId, selectedAnswerId } = body;
2419

25-
if (!correctAnswersMap) {
26-
return NextResponse.json(
27-
{ error: 'Invalid encrypted data' },
28-
{ status: 400 }
29-
);
30-
}
20+
let correctAnswerId = await getCorrectAnswer(quizId, questionId);
3121

32-
const correctAnswerId = correctAnswersMap[questionId];
22+
if (!correctAnswerId) {
23+
const cacheReady = await getOrCreateQuizAnswersCache(quizId);
24+
if (cacheReady) {
25+
correctAnswerId = await getCorrectAnswer(quizId, questionId);
26+
}
27+
}
3328

3429
if (!correctAnswerId) {
3530
return NextResponse.json(
36-
{ error: 'Question not found' },
31+
{ success: false, error: 'Question not found in cache' },
3732
{ status: 404 }
3833
);
3934
}
4035

36+
const isCorrect = selectedAnswerId === correctAnswerId;
37+
4138
return NextResponse.json({
42-
isCorrect: answerId === correctAnswerId,
39+
success: true,
40+
isCorrect,
4341
});
44-
} catch {
42+
} catch (error) {
43+
console.error('Failed to verify answer:', error);
4544
return NextResponse.json(
46-
{ error: 'Internal server error' },
45+
{ success: false, error: 'Internal server error' },
4746
{ status: 500 }
4847
);
4948
}

0 commit comments

Comments
 (0)