diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index d47f8b9..168d2d1 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -111,12 +111,12 @@ describe("POST /api/v1/game/start", () => { expect(body.success).toBe(false); }); - it("returns 404 when no terms are found for the given filters", async () => { + it("returns 422 when no terms are found for the given filters", async () => { mockGetGameTerms.mockResolvedValue([]); const res = await request(app).post("/api/v1/game/start").send(validBody); const body = res.body as ErrorResponse; - expect(res.status).toBe(404); + expect(res.status).toBe(422); expect(body.success).toBe(false); }); @@ -178,7 +178,7 @@ describe("POST /api/v1/game/answer", () => { expect(body.error).toContain("Game session not found"); }); - it("returns 404 when the question does not exist in the session", async () => { + it("returns 409 when the question does not exist in the session", async () => { const startRes = await request(app) .post("/api/v1/game/start") .send(validBody); @@ -193,11 +193,11 @@ describe("POST /api/v1/game/answer", () => { selectedOptionId: 0, }); const body = res.body as ErrorResponse; - expect(res.status).toBe(404); + expect(res.status).toBe(409); expect(body.success).toBe(false); - expect(body.error).toContain("Question not found"); + expect(body.error).toContain("Question already answered"); }); - + it("returns 400 when a field has an invalid value", async () => { const res = await request(app) .post("/api/v1/game/start") diff --git a/apps/api/src/errors/AppError.ts b/apps/api/src/errors/AppError.ts index 4677d9f..7805f3e 100644 --- a/apps/api/src/errors/AppError.ts +++ b/apps/api/src/errors/AppError.ts @@ -25,3 +25,9 @@ export class ConflictError extends AppError { super(message, 409); } } + +export class UnprocessableEntityError extends AppError { + constructor(message: string) { + super(message, 422); + } +} \ No newline at end of file diff --git a/apps/api/src/gameSessionStore/GameSessionStore.ts b/apps/api/src/gameSessionStore/GameSessionStore.ts index 14e27b0..3e6c5d2 100644 --- a/apps/api/src/gameSessionStore/GameSessionStore.ts +++ b/apps/api/src/gameSessionStore/GameSessionStore.ts @@ -1,4 +1,7 @@ -export type GameSessionData = { answers: Map; userId: string }; +export type GameSessionData = { + answers: Map; + userId: string; +}; export interface GameSessionStore { create( @@ -9,4 +12,4 @@ export interface GameSessionStore { get(sessionId: string): Promise; update(sessionId: string, data: GameSessionData): Promise; delete(sessionId: string): Promise; -} \ No newline at end of file +} diff --git a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts index fae0365..d08be1f 100644 --- a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts @@ -14,14 +14,20 @@ describe("InMemoryGameSessionStore", () => { }); it("returns session data after creation", async () => { - const data = { answers: new Map([["q1", 2]]), userId: "user-1" }; + const data = { + answers: new Map([["q1", { correctOptionId: 2 }]]), + userId: "user-1", + }; 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]]), userId: "user-1" }; + const data = { + answers: new Map([["q1", { correctOptionId: 2 }]]), + userId: "user-1", + }; await store.create("session-1", data, 60_000); await store.delete("session-1"); const result = await store.get("session-1"); @@ -29,7 +35,10 @@ describe("InMemoryGameSessionStore", () => { }); it("returns null after TTL expires", async () => { - const data = { answers: new Map([["q1", 2]]), userId: "user-1" }; + const data = { + answers: new Map([["q1", { correctOptionId: 2 }]]), + userId: "user-1", + }; await store.create("session-1", data, 1); await new Promise((resolve) => setTimeout(resolve, 10)); const result = await store.get("session-1"); @@ -37,7 +46,10 @@ describe("InMemoryGameSessionStore", () => { }); it("returns session data before TTL expires", async () => { - const data = { answers: new Map([["q1", 2]]), userId: "user-1" }; + const data = { + answers: new Map([["q1", { correctOptionId: 2 }]]), + userId: "user-1", + }; await store.create("session-1", data, 60_000); const result = await store.get("session-1"); expect(result).not.toBeNull(); @@ -45,15 +57,16 @@ describe("InMemoryGameSessionStore", () => { it("update persists modified session data", async () => { const data = { - answers: new Map([ - ["q1", 2], - ["q2", 1], - ]), + answers: new Map([["q1", { correctOptionId: 2 }]]), userId: "user-1", }; + await store.create("session-1", data, 60_000); - const updated = { answers: new Map([["q2", 1]]), userId: "user-1" }; + const updated = { + answers: new Map([["q2", { correctOptionId: 1 }]]), + userId: "user-1", + }; await store.update("session-1", updated); const result = await store.get("session-1"); diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 98cad3b..76fa3a2 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -255,7 +255,7 @@ describe("evaluateAnswer", () => { ); }); - it("throws NotFoundError for a non-existent question", async () => { + it("throws ConflictError for a non-existent question", async () => { const session = await createGameSession(validRequest, store, "user-1"); const submission: AnswerSubmission = { @@ -264,12 +264,12 @@ describe("evaluateAnswer", () => { selectedOptionId: 0, }; - await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow( - "Question not found", - ); + await expect( + evaluateAnswer(submission, store, "user-1"), + ).rejects.toMatchObject({ statusCode: 409 }); }); - it("throws NotFoundError when the same question is submitted twice", async () => { + it("throws ConflictError when the same question is submitted twice", async () => { const session = await createGameSession(validRequest, store, "user-1"); const question = session.questions[0]!; @@ -293,7 +293,7 @@ describe("evaluateAnswer", () => { store, "user-1", ), - ).rejects.toThrow("Question not found"); + ).rejects.toMatchObject({ statusCode: 409 }); }); it("deletes the session after the last question is answered", async () => { @@ -324,11 +324,11 @@ describe("evaluateAnswer", () => { ).rejects.toThrow("Game session not found"); }); - it("throws NotFoundError when getGameTerms returns no terms", async () => { + it("throws UnprocessableEntityError when getGameTerms returns no terms", async () => { mockGetGameTerms.mockResolvedValue([]); await expect( createGameSession(validRequest, store, "user-1"), - ).rejects.toThrow("No terms found"); + ).rejects.toMatchObject({ statusCode: 422 }); }); }); diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index 573f7a8..ad34c72 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -9,7 +9,11 @@ import type { AnswerResult, } from "@lila/shared"; import type { GameSessionStore } from "../gameSessionStore/index.js"; -import { NotFoundError } from "../errors/AppError.js"; +import { + NotFoundError, + ConflictError, + UnprocessableEntityError, +} from "../errors/AppError.js"; import { shuffleArray } from "../lib/utils.js"; export const createGameSession = async ( @@ -26,10 +30,10 @@ export const createGameSession = async ( ); if (terms.length === 0) { - throw new NotFoundError("No terms found for the given filters"); + throw new UnprocessableEntityError("No terms found for the given filters"); } - const answerKey = new Map(); + const answerKey = new Map(); const questions: GameQuestion[] = await Promise.all( terms.map(async (term) => { @@ -62,7 +66,7 @@ export const createGameSession = async ( })); const questionId = randomUUID(); - answerKey.set(questionId, correctOptionId); + answerKey.set(questionId, { correctOptionId }); return { questionId, @@ -90,10 +94,12 @@ export const evaluateAnswer = async ( throw new NotFoundError(`Game session not found: ${submission.sessionId}`); } - const correctOptionId = session.answers.get(submission.questionId); + const answer = session.answers.get(submission.questionId); - if (correctOptionId === undefined) { - throw new NotFoundError(`Question not found: ${submission.questionId}`); + if (answer === undefined) { + throw new ConflictError( + `Question already answered: ${submission.questionId}`, + ); } const updatedAnswers = new Map(session.answers); @@ -110,8 +116,8 @@ export const evaluateAnswer = async ( return { questionId: submission.questionId, - isCorrect: submission.selectedOptionId === correctOptionId, - correctOptionId, + isCorrect: submission.selectedOptionId === answer.correctOptionId, + correctOptionId: answer.correctOptionId, selectedOptionId: submission.selectedOptionId, }; };