diff --git a/.gitignore b/.gitignore index 07fc409..f8dbdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ data-pipeline/stage-4-merge/output/ data-pipeline/db/pipeline.db data-pipeline/reports/ data-pipeline/.env +.aider* diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index d45298a..83b5672 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; import type { GameSession, AnswerResult } from "@lila/shared"; +import { auth } from "../lib/auth.js"; type SuccessResponse = { success: true; data: T }; type ErrorResponse = { success: false; error: string }; @@ -48,6 +49,36 @@ vi.mock("better-auth/node", () => ({ toNodeHandler: vi.fn().mockReturnValue(vi.fn()), })); +const mockGetSession = vi.mocked(auth.api.getSession); + +function setAuthenticatedUser(userId: string) { + mockGetSession.mockResolvedValue({ + session: { + id: "session-1", + userId, + token: "fake-token", + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + }, + user: { + id: userId, + name: "Test User", + email: "test@test.com", + emailVerified: false, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +function setGuestUser() { + mockGetSession.mockResolvedValue(null); +} + import { getGameTerms, getDistractors } from "@lila/db"; import { createApp } from "../app.js"; @@ -76,6 +107,7 @@ const fakeTerms = [ beforeEach(() => { vi.clearAllMocks(); + setAuthenticatedUser("user-1"); // default: authenticated mockGetGameTerms.mockResolvedValue(fakeTerms); mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); }); @@ -221,3 +253,87 @@ describe("POST /api/v1/game/answer", () => { expect(body.success).toBe(false); }); }); + +describe("POST /api/v1/game/start — guest", () => { + beforeEach(() => { + setGuestUser(); + }); + + it("returns 200 for a guest with valid game settings", async () => { + const res = await request(app).post("/api/v1/game/start").send(validBody); + const body = res.body as GameStartResponse; + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.sessionId).toBeDefined(); + expect(body.data.questions).toHaveLength(3); + }); + + it("creates a session without userId for guests", async () => { + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + + // Guest can answer — no 404 from userId mismatch + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: questions[0]!.questionId, + selectedOptionId: 0, + }); + expect(res.status).toBe(200); + }); +}); + +describe("POST /api/v1/game/answer — guest", () => { + beforeEach(() => { + setGuestUser(); + }); + + it("allows a guest to submit an answer", async () => { + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + const question = questions[0]!; + + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }); + const body = res.body as GameAnswerResponse; + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.questionId).toBe(question.questionId); + }); + + it("returns 404 when a guest tries to answer an authenticated user's session", async () => { + // First: create session as authenticated user + setAuthenticatedUser("user-1"); + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + + // Then: try to answer as guest + setGuestUser(); + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: questions[0]!.questionId, + selectedOptionId: 0, + }); + const body = res.body as ErrorResponse; + expect(res.status).toBe(404); + expect(body.success).toBe(false); + expect(body.error).toContain("Game session not found"); + }); +}); diff --git a/apps/api/src/controllers/gameController.ts b/apps/api/src/controllers/gameController.ts index 2a0416e..ea68cce 100644 --- a/apps/api/src/controllers/gameController.ts +++ b/apps/api/src/controllers/gameController.ts @@ -16,10 +16,11 @@ export const createGameController = (store: GameSessionStore) => ({ if (!gameSettings.success) { throw new ValidationError("Invalid game settings"); } + const userId = req.session?.user.id ?? null; const gameQuestions = await createGameSession( gameSettings.data, store, - req.session.user.id, + userId, ); res.json({ success: true, data: gameQuestions }); } catch (error) { @@ -37,11 +38,8 @@ export const createGameController = (store: GameSessionStore) => ({ if (!submission.success) { throw new ValidationError("Invalid answer submission"); } - const result = await evaluateAnswer( - submission.data, - store, - req.session.user.id, - ); + const userId = req.session?.user.id ?? null; + const result = await evaluateAnswer(submission.data, store, userId); res.json({ success: true, data: result }); } catch (error) { next(error); diff --git a/apps/api/src/middleware/authMiddleware.ts b/apps/api/src/middleware/authMiddleware.ts index 744c5e4..7a161f1 100644 --- a/apps/api/src/middleware/authMiddleware.ts +++ b/apps/api/src/middleware/authMiddleware.ts @@ -20,3 +20,23 @@ export const requireAuth = async ( next(); }; + +export const optionalAuth = async ( + req: Request, + _res: Response, + next: NextFunction, +) => { + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (session) { + req.session = session; + } + } catch (err) { + console.warn("Auth check failed, continuing as guest:", err); + } + + next(); +}; diff --git a/apps/api/src/middleware/rateLimiters.ts b/apps/api/src/middleware/rateLimiters.ts index 2f2eaf6..4dfcc79 100644 --- a/apps/api/src/middleware/rateLimiters.ts +++ b/apps/api/src/middleware/rateLimiters.ts @@ -1,4 +1,4 @@ -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import type { Request } from "express"; // TODO: When Valkey is wired up, swap the default in-memory store for @@ -33,7 +33,8 @@ export const gameLimiter = rateLimit({ limit: 150, standardHeaders: "draft-8", legacyHeaders: false, - keyGenerator: (req: Request) => req.session!.user.id, + keyGenerator: (req: Request) => + req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"), message: { success: false, error: "Too many requests, please try again later.", @@ -45,7 +46,8 @@ export const lobbyLimiter = rateLimit({ limit: 20, standardHeaders: "draft-8", legacyHeaders: false, - keyGenerator: (req: Request) => req.session!.user.id, + keyGenerator: (req: Request) => + req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"), message: { success: false, error: "Too many requests, please try again later.", diff --git a/apps/api/src/routes/gameRouter.ts b/apps/api/src/routes/gameRouter.ts index 9e29a5d..9bc3e53 100644 --- a/apps/api/src/routes/gameRouter.ts +++ b/apps/api/src/routes/gameRouter.ts @@ -1,7 +1,7 @@ import express from "express"; import type { Router } from "express"; import { createGameController } from "../controllers/gameController.js"; -import { requireAuth } from "../middleware/authMiddleware.js"; +import { optionalAuth } from "../middleware/authMiddleware.js"; import { gameLimiter } from "../middleware/rateLimiters.js"; import type { GameSessionStore } from "../gameSessionStore/index.js"; @@ -9,11 +9,11 @@ export const createGameRouter = (store: GameSessionStore): Router => { const router = express.Router(); const controller = createGameController(store); - router.use(requireAuth); + router.use(optionalAuth); router.use(gameLimiter); router.post("/start", controller.createGame as express.RequestHandler); router.post("/answer", controller.submitAnswer as express.RequestHandler); - + return router; }; diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 160d816..97bf075 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -332,3 +332,89 @@ describe("evaluateAnswer", () => { ).rejects.toMatchObject({ statusCode: 422 }); }); }); + +// Add to existing gameService.test.ts + +describe("createGameSession — guest", () => { + let store: InMemoryGameSessionStore; + + beforeEach(() => { + store = new InMemoryGameSessionStore(); + }); + + it("creates a session with userId null for guests", async () => { + const session = await createGameSession(validRequest, store, null); + + expect(session.sessionId).toBeDefined(); + expect(session.questions).toHaveLength(3); + }); + + it("stores userId as null in the session store", async () => { + const session = await createGameSession(validRequest, store, null); + const stored = await store.get(session.sessionId); + + expect(stored).not.toBeNull(); + expect(stored!.userId).toBeNull(); + }); +}); + +describe("evaluateAnswer — guest", () => { + let store: InMemoryGameSessionStore; + + beforeEach(() => { + store = new InMemoryGameSessionStore(); + }); + + it("allows a guest to answer their own session", async () => { + const session = await createGameSession(validRequest, store, null); + const question = session.questions[0]!; + const correctText = fakeTerms[0]!.targetText; + const correctOption = question.options.find((o) => o.text === correctText)!; + + const result = await evaluateAnswer( + { + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: correctOption.optionId, + }, + store, + null, + ); + + expect(result.isCorrect).toBe(true); + }); + + it("throws NotFoundError when guest tries to answer an authenticated session", async () => { + const authSession = await createGameSession(validRequest, store, "user-1"); + const question = authSession.questions[0]!; + + await expect( + evaluateAnswer( + { + sessionId: authSession.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + null, + ), + ).rejects.toThrow("Game session not found"); + }); + + it("throws NotFoundError when authenticated user tries to answer a guest session", async () => { + const guestSession = await createGameSession(validRequest, store, null); + const question = guestSession.questions[0]!; + + await expect( + evaluateAnswer( + { + sessionId: guestSession.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + "user-1", + ), + ).rejects.toThrow("Game session not found"); + }); +}); diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index a31014a..550fde2 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -19,7 +19,7 @@ import { shuffleArray } from "../lib/utils.js"; export const createGameSession = async ( request: GameRequest, store: GameSessionStore, - userId: string, + userId: string | null, ): Promise => { const terms = await getGameTerms( request.source_language, @@ -87,11 +87,15 @@ export const createGameSession = async ( export const evaluateAnswer = async ( submission: AnswerSubmission, store: GameSessionStore, - userId: string, + userId: string | null, ): Promise => { const session = await store.get(submission.sessionId); - if (!session || session.userId !== userId) { + if (!session) { + throw new NotFoundError(`Game session not found: ${submission.sessionId}`); + } + + if (session.userId !== userId) { throw new NotFoundError(`Game session not found: ${submission.sessionId}`); } diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts index da7633a..a310394 100644 --- a/apps/api/src/types/express.d.ts +++ b/apps/api/src/types/express.d.ts @@ -18,3 +18,7 @@ declare module "ws" { export type AuthenticatedRequest = Request & { session: { session: Session; user: User }; }; + +export type GuestRequest = Request & { + session?: { session: Session; user: User }; +}; diff --git a/apps/web/src/components/game/ScoreScreen.tsx b/apps/web/src/components/game/ScoreScreen.tsx index ac64da2..5206209 100644 --- a/apps/web/src/components/game/ScoreScreen.tsx +++ b/apps/web/src/components/game/ScoreScreen.tsx @@ -1,9 +1,17 @@ import type { AnswerResult } from "@lila/shared"; import { ConfettiBurst } from "../ui/ConfettiBurst"; -type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void }; +type ScoreScreenProps = { + results: AnswerResult[]; + onPlayAgain: () => void; + showAuthPrompt?: boolean; +}; -export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { +export const ScoreScreen = ({ + results, + onPlayAgain, + showAuthPrompt = false, +}: ScoreScreenProps) => { const score = results.filter((r) => r.isCorrect).length; const total = results.length; const percentage = Math.round((score / total) * 100); @@ -58,12 +66,34 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { ))} - + {showAuthPrompt && ( +
+

+ Want to save your progress and compete with friends? +

+ + Create an account + + +
+ )} + + {!showAuthPrompt && ( + + )} ); }; diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index bc4cde3..f2c85ee 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -1,5 +1,5 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { useState, useCallback } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useState, useCallback, useEffect } from "react"; import type { GameSession, GameRequest, AnswerResult } from "@lila/shared"; import { QuestionCard } from "../components/game/QuestionCard"; import { ScoreScreen } from "../components/game/ScoreScreen"; @@ -18,6 +18,13 @@ function Play() { const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [results, setResults] = useState([]); const [currentResult, setCurrentResult] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + void authClient.getSession().then(({ data }) => { + setIsAuthenticated(!!data); + }); + }, []); const startGame = useCallback(async (settings: GameRequest) => { setIsLoading(true); @@ -100,7 +107,11 @@ function Play() { if (currentQuestionIndex >= gameSession.questions.length) { return (
- +
); } @@ -129,10 +140,4 @@ function Play() { export const Route = createFileRoute("/play")({ component: Play, errorComponent: RouteError, - beforeLoad: async () => { - const { data: session } = await authClient.getSession(); - if (!session) { - throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } }); - } - }, });