fix: improve error semantics, clarify answer key type

This commit is contained in:
lila 2026-04-28 16:07:19 +02:00
parent 6eaf282651
commit 648c5d2979
6 changed files with 62 additions and 34 deletions

View file

@ -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,9 +193,9 @@ 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 () => {

View file

@ -25,3 +25,9 @@ export class ConflictError extends AppError {
super(message, 409);
}
}
export class UnprocessableEntityError extends AppError {
constructor(message: string) {
super(message, 422);
}
}

View file

@ -1,4 +1,7 @@
export type GameSessionData = { answers: Map<string, number>; userId: string };
export type GameSessionData = {
answers: Map<string, { correctOptionId: number }>;
userId: string;
};
export interface GameSessionStore {
create(

View file

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

View file

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

View file

@ -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<string, number>();
const answerKey = new Map<string, { correctOptionId: number }>();
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,
};
};