fix: improve error semantics, clarify answer key type
This commit is contained in:
parent
6eaf282651
commit
648c5d2979
6 changed files with 62 additions and 34 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -25,3 +25,9 @@ export class ConflictError extends AppError {
|
|||
super(message, 409);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnprocessableEntityError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 422);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue