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); 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([]); mockGetGameTerms.mockResolvedValue([]);
const res = await request(app).post("/api/v1/game/start").send(validBody); const res = await request(app).post("/api/v1/game/start").send(validBody);
const body = res.body as ErrorResponse; const body = res.body as ErrorResponse;
expect(res.status).toBe(404); expect(res.status).toBe(422);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
@ -178,7 +178,7 @@ describe("POST /api/v1/game/answer", () => {
expect(body.error).toContain("Game session not found"); 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) const startRes = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")
.send(validBody); .send(validBody);
@ -193,11 +193,11 @@ describe("POST /api/v1/game/answer", () => {
selectedOptionId: 0, selectedOptionId: 0,
}); });
const body = res.body as ErrorResponse; const body = res.body as ErrorResponse;
expect(res.status).toBe(404); expect(res.status).toBe(409);
expect(body.success).toBe(false); 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 () => { it("returns 400 when a field has an invalid value", async () => {
const res = await request(app) const res = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")

View file

@ -25,3 +25,9 @@ export class ConflictError extends AppError {
super(message, 409); 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 { export interface GameSessionStore {
create( create(
@ -9,4 +12,4 @@ export interface GameSessionStore {
get(sessionId: string): Promise<GameSessionData | null>; get(sessionId: string): Promise<GameSessionData | null>;
update(sessionId: string, data: GameSessionData): Promise<void>; update(sessionId: string, data: GameSessionData): Promise<void>;
delete(sessionId: string): Promise<void>; delete(sessionId: string): Promise<void>;
} }

View file

@ -14,14 +14,20 @@ describe("InMemoryGameSessionStore", () => {
}); });
it("returns session data after creation", async () => { 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); await store.create("session-1", data, 60_000);
const result = await store.get("session-1"); const result = await store.get("session-1");
expect(result).toEqual(data); expect(result).toEqual(data);
}); });
it("returns null after the session is deleted", async () => { 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.create("session-1", data, 60_000);
await store.delete("session-1"); await store.delete("session-1");
const result = await store.get("session-1"); const result = await store.get("session-1");
@ -29,7 +35,10 @@ describe("InMemoryGameSessionStore", () => {
}); });
it("returns null after TTL expires", async () => { 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 store.create("session-1", data, 1);
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
const result = await store.get("session-1"); const result = await store.get("session-1");
@ -37,7 +46,10 @@ describe("InMemoryGameSessionStore", () => {
}); });
it("returns session data before TTL expires", async () => { 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); await store.create("session-1", data, 60_000);
const result = await store.get("session-1"); const result = await store.get("session-1");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
@ -45,15 +57,16 @@ describe("InMemoryGameSessionStore", () => {
it("update persists modified session data", async () => { it("update persists modified session data", async () => {
const data = { const data = {
answers: new Map([ answers: new Map([["q1", { correctOptionId: 2 }]]),
["q1", 2],
["q2", 1],
]),
userId: "user-1", userId: "user-1",
}; };
await store.create("session-1", data, 60_000); 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); await store.update("session-1", updated);
const result = await store.get("session-1"); 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 session = await createGameSession(validRequest, store, "user-1");
const submission: AnswerSubmission = { const submission: AnswerSubmission = {
@ -264,12 +264,12 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0, selectedOptionId: 0,
}; };
await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow( await expect(
"Question not found", 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 session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!; const question = session.questions[0]!;
@ -293,7 +293,7 @@ describe("evaluateAnswer", () => {
store, store,
"user-1", "user-1",
), ),
).rejects.toThrow("Question not found"); ).rejects.toMatchObject({ statusCode: 409 });
}); });
it("deletes the session after the last question is answered", async () => { it("deletes the session after the last question is answered", async () => {
@ -324,11 +324,11 @@ describe("evaluateAnswer", () => {
).rejects.toThrow("Game session not found"); ).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([]); mockGetGameTerms.mockResolvedValue([]);
await expect( await expect(
createGameSession(validRequest, store, "user-1"), createGameSession(validRequest, store, "user-1"),
).rejects.toThrow("No terms found"); ).rejects.toMatchObject({ statusCode: 422 });
}); });
}); });

View file

@ -9,7 +9,11 @@ import type {
AnswerResult, AnswerResult,
} from "@lila/shared"; } from "@lila/shared";
import type { GameSessionStore } from "../gameSessionStore/index.js"; 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"; import { shuffleArray } from "../lib/utils.js";
export const createGameSession = async ( export const createGameSession = async (
@ -26,10 +30,10 @@ export const createGameSession = async (
); );
if (terms.length === 0) { 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( const questions: GameQuestion[] = await Promise.all(
terms.map(async (term) => { terms.map(async (term) => {
@ -62,7 +66,7 @@ export const createGameSession = async (
})); }));
const questionId = randomUUID(); const questionId = randomUUID();
answerKey.set(questionId, correctOptionId); answerKey.set(questionId, { correctOptionId });
return { return {
questionId, questionId,
@ -90,10 +94,12 @@ export const evaluateAnswer = async (
throw new NotFoundError(`Game session not found: ${submission.sessionId}`); 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) { if (answer === undefined) {
throw new NotFoundError(`Question not found: ${submission.questionId}`); throw new ConflictError(
`Question already answered: ${submission.questionId}`,
);
} }
const updatedAnswers = new Map(session.answers); const updatedAnswers = new Map(session.answers);
@ -110,8 +116,8 @@ export const evaluateAnswer = async (
return { return {
questionId: submission.questionId, questionId: submission.questionId,
isCorrect: submission.selectedOptionId === correctOptionId, isCorrect: submission.selectedOptionId === answer.correctOptionId,
correctOptionId, correctOptionId: answer.correctOptionId,
selectedOptionId: submission.selectedOptionId, selectedOptionId: submission.selectedOptionId,
}; };
}; };