feat: add TTL to GameSessionStore, replay protection and session cleanup to evaluateAnswer
This commit is contained in:
parent
54705943fa
commit
fdeb769640
6 changed files with 218 additions and 7 deletions
|
|
@ -1,7 +1,11 @@
|
|||
export type GameSessionData = { answers: Map<string, number> };
|
||||
|
||||
export interface GameSessionStore {
|
||||
create(sessionId: string, data: GameSessionData): Promise<void>;
|
||||
create(
|
||||
sessionId: string,
|
||||
data: GameSessionData,
|
||||
ttlMs: number,
|
||||
): Promise<void>;
|
||||
get(sessionId: string): Promise<GameSessionData | null>;
|
||||
delete(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { InMemoryGameSessionStore } from "./InMemoryGameSessionStore.js";
|
||||
|
||||
describe("InMemoryGameSessionStore", () => {
|
||||
let store: InMemoryGameSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemoryGameSessionStore();
|
||||
});
|
||||
|
||||
it("returns null for a non-existent session", async () => {
|
||||
const result = await store.get("00000000-0000-0000-0000-000000000000");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns session data after creation", async () => {
|
||||
const data = { answers: new Map([["q1", 2]]) };
|
||||
await store.create("session-1", data, 60_000);
|
||||
const result = await store.get("session-1");
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it("returns null after the session is deleted", async () => {
|
||||
const data = { answers: new Map([["q1", 2]]) };
|
||||
await store.create("session-1", data, 60_000);
|
||||
await store.delete("session-1");
|
||||
const result = await store.get("session-1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null after TTL expires", async () => {
|
||||
const data = { answers: new Map([["q1", 2]]) };
|
||||
await store.create("session-1", data, 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const result = await store.get("session-1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns session data before TTL expires", async () => {
|
||||
const data = { answers: new Map([["q1", 2]]) };
|
||||
await store.create("session-1", data, 60_000);
|
||||
const result = await store.get("session-1");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,27 @@
|
|||
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
||||
|
||||
export class InMemoryGameSessionStore implements GameSessionStore {
|
||||
private sessions = new Map<string, GameSessionData>();
|
||||
type SessionEntry = { data: GameSessionData; expiresAt: number };
|
||||
|
||||
create(sessionId: string, data: GameSessionData): Promise<void> {
|
||||
this.sessions.set(sessionId, data);
|
||||
export class InMemoryGameSessionStore implements GameSessionStore {
|
||||
private sessions = new Map<string, SessionEntry>();
|
||||
|
||||
create(
|
||||
sessionId: string,
|
||||
data: GameSessionData,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
this.sessions.set(sessionId, { data, expiresAt: Date.now() + ttlMs });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
get(sessionId: string): Promise<GameSessionData | null> {
|
||||
return Promise.resolve(this.sessions.get(sessionId) ?? null);
|
||||
const entry = this.sessions.get(sessionId);
|
||||
if (!entry) return Promise.resolve(null);
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.sessions.delete(sessionId);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(entry.data);
|
||||
}
|
||||
|
||||
delete(sessionId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -219,4 +219,55 @@ describe("evaluateAnswer", () => {
|
|||
"Question not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NotFoundError when the same question is submitted twice", async () => {
|
||||
const session = await createGameSession(validRequest, store);
|
||||
const question = session.questions[0]!;
|
||||
|
||||
await evaluateAnswer(
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
questionId: question.questionId,
|
||||
selectedOptionId: 0,
|
||||
},
|
||||
store,
|
||||
);
|
||||
|
||||
await expect(
|
||||
evaluateAnswer(
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
questionId: question.questionId,
|
||||
selectedOptionId: 0,
|
||||
},
|
||||
store,
|
||||
),
|
||||
).rejects.toThrow("Question not found");
|
||||
});
|
||||
|
||||
it("deletes the session after the last question is answered", async () => {
|
||||
const session = await createGameSession(validRequest, store);
|
||||
|
||||
for (const question of session.questions) {
|
||||
await evaluateAnswer(
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
questionId: question.questionId,
|
||||
selectedOptionId: 0,
|
||||
},
|
||||
store,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(
|
||||
evaluateAnswer(
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
questionId: session.questions[0]!.questionId,
|
||||
selectedOptionId: 0,
|
||||
},
|
||||
store,
|
||||
),
|
||||
).rejects.toThrow("Game session not found");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const createGameSession = async (
|
|||
);
|
||||
|
||||
const sessionId = randomUUID();
|
||||
await store.create(sessionId, { answers: answerKey });
|
||||
await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000);
|
||||
|
||||
return { sessionId, questions };
|
||||
};
|
||||
|
|
@ -80,6 +80,12 @@ export const evaluateAnswer = async (
|
|||
throw new NotFoundError(`Question not found: ${submission.questionId}`);
|
||||
}
|
||||
|
||||
session.answers.delete(submission.questionId);
|
||||
|
||||
if (session.answers.size === 0) {
|
||||
await store.delete(submission.sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
questionId: submission.questionId,
|
||||
isCorrect: submission.selectedOptionId === correctOptionId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue