feat: guest play — allow singleplayer quiz without auth
- Add optionalAuth middleware: attaches session when present, never blocks (guests pass through) - Make game endpoints (start/answer) accept optional auth - GameSessionStore.userId: string → string | null - Rate limiter falls back to IP for unauthenticated users - Frontend: remove /play route guard, show 'Create account' CTA on score screen for guests - Add tests for guest session creation, answer submission, and cross-user session isolation
This commit is contained in:
parent
d55a1ed648
commit
0118798e36
11 changed files with 298 additions and 32 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,3 +19,4 @@ data-pipeline/stage-4-merge/output/
|
|||
data-pipeline/db/pipeline.db
|
||||
data-pipeline/reports/
|
||||
data-pipeline/.env
|
||||
.aider*
|
||||
|
|
|
|||
|
|
@ -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<T> = { 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,7 +9,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { shuffleArray } from "../lib/utils.js";
|
|||
export const createGameSession = async (
|
||||
request: GameRequest,
|
||||
store: GameSessionStore,
|
||||
userId: string,
|
||||
userId: string | null,
|
||||
): Promise<GameSession> => {
|
||||
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<AnswerResult> => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
4
apps/api/src/types/express.d.ts
vendored
4
apps/api/src/types/express.d.ts
vendored
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{showAuthPrompt && (
|
||||
<div className="w-full rounded-2xl border border-(--color-primary-light) bg-white/60 dark:bg-black/10 backdrop-blur p-6 text-center">
|
||||
<p className="text-sm text-(--color-text-muted) mb-3">
|
||||
Want to save your progress and compete with friends?
|
||||
</p>
|
||||
<a
|
||||
href="/?modal=auth&redirect=/play"
|
||||
className="inline-block w-full py-3 rounded-xl text-base font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200"
|
||||
>
|
||||
Create an account
|
||||
</a>
|
||||
<button
|
||||
onClick={onPlayAgain}
|
||||
className="mt-3 text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors cursor-pointer"
|
||||
>
|
||||
Continue as guest →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAuthPrompt && (
|
||||
<button
|
||||
onClick={onPlayAgain}
|
||||
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Play again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AnswerResult[]>([]);
|
||||
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(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 (
|
||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||
<ScoreScreen results={results} onPlayAgain={resetToSetup} />
|
||||
<ScoreScreen
|
||||
results={results}
|
||||
onPlayAgain={resetToSetup}
|
||||
showAuthPrompt={isAuthenticated === false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue