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:
lila 2026-05-31 21:28:08 +02:00
parent d55a1ed648
commit 0118798e36
11 changed files with 298 additions and 32 deletions

View file

@ -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");
});
});

View file

@ -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}`);
}