refactor: dependency injection for GameSessionStore via composition root
This commit is contained in:
parent
4f59f3bc14
commit
a4a4bfff57
6 changed files with 107 additions and 79 deletions
|
|
@ -4,7 +4,8 @@ import { toNodeHandler } from "better-auth/node";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import { auth } from "./lib/auth.js";
|
import { auth } from "./lib/auth.js";
|
||||||
import { apiRouter } from "./routes/apiRouter.js";
|
import { createApiRouter } from "./routes/apiRouter.js";
|
||||||
|
import { InMemoryGameSessionStore } from "./gameSessionStore/index.js";
|
||||||
import { errorHandler } from "./middleware/errorHandler.js";
|
import { errorHandler } from "./middleware/errorHandler.js";
|
||||||
import { authLimiter } from "./middleware/rateLimiters.js";
|
import { authLimiter } from "./middleware/rateLimiters.js";
|
||||||
|
|
||||||
|
|
@ -23,7 +24,10 @@ export function createApp() {
|
||||||
app.use("/api/auth", authLimiter);
|
app.use("/api/auth", authLimiter);
|
||||||
app.all("/api/auth/*splat", toNodeHandler(auth));
|
app.all("/api/auth/*splat", toNodeHandler(auth));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use("/api/v1", apiRouter);
|
|
||||||
|
const store = new InMemoryGameSessionStore();
|
||||||
|
app.use("/api/v1", createApiRouter(store));
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -2,41 +2,32 @@ import type { Request, Response, NextFunction } from "express";
|
||||||
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
|
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
|
||||||
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
|
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
|
||||||
import { ValidationError } from "../errors/AppError.js";
|
import { ValidationError } from "../errors/AppError.js";
|
||||||
|
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
||||||
|
|
||||||
export const createGame = async (
|
export const createGameController = (store: GameSessionStore) => ({
|
||||||
req: Request,
|
createGame: async (req: Request, res: Response, next: NextFunction) => {
|
||||||
res: Response,
|
try {
|
||||||
next: NextFunction,
|
const gameSettings = GameRequestSchema.safeParse(req.body);
|
||||||
) => {
|
if (!gameSettings.success) {
|
||||||
try {
|
throw new ValidationError(gameSettings.error.message);
|
||||||
const gameSettings = GameRequestSchema.safeParse(req.body);
|
}
|
||||||
|
const gameQuestions = await createGameSession(gameSettings.data, store);
|
||||||
if (!gameSettings.success) {
|
res.json({ success: true, data: gameQuestions });
|
||||||
throw new ValidationError(gameSettings.error.message);
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
const gameQuestions = await createGameSession(gameSettings.data);
|
submitAnswer: async (req: Request, res: Response, next: NextFunction) => {
|
||||||
res.json({ success: true, data: gameQuestions });
|
try {
|
||||||
} catch (error) {
|
const submission = AnswerSubmissionSchema.safeParse(req.body);
|
||||||
next(error);
|
if (!submission.success) {
|
||||||
}
|
throw new ValidationError(submission.error.message);
|
||||||
};
|
}
|
||||||
|
const result = await evaluateAnswer(submission.data, store);
|
||||||
export const submitAnswer = async (
|
res.json({ success: true, data: result });
|
||||||
req: Request,
|
} catch (error) {
|
||||||
res: Response,
|
next(error);
|
||||||
next: NextFunction,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const submission = AnswerSubmissionSchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!submission.success) {
|
|
||||||
throw new ValidationError(submission.error.message);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const result = await evaluateAnswer(submission.data);
|
});
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { Router } from "express";
|
import type { Router } from "express";
|
||||||
import { healthRouter } from "./healthRouter.js";
|
import { healthRouter } from "./healthRouter.js";
|
||||||
import { gameRouter } from "./gameRouter.js";
|
import { createGameRouter } from "./gameRouter.js";
|
||||||
import { lobbyRouter } from "./lobbyRouter.js";
|
import { lobbyRouter } from "./lobbyRouter.js";
|
||||||
|
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
||||||
|
|
||||||
export const apiRouter: Router = express.Router();
|
export const createApiRouter = (store: GameSessionStore): Router => {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
apiRouter.use("/health", healthRouter);
|
router.use("/health", healthRouter);
|
||||||
apiRouter.use("/game", gameRouter);
|
router.use("/game", createGameRouter(store));
|
||||||
apiRouter.use("/lobbies", lobbyRouter);
|
router.use("/lobbies", lobbyRouter);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import { createGame, submitAnswer } from "../controllers/gameController.js";
|
import { createGameController } from "../controllers/gameController.js";
|
||||||
import { requireAuth } from "../middleware/authMiddleware.js";
|
import { requireAuth } from "../middleware/authMiddleware.js";
|
||||||
import { gameLimiter } from "../middleware/rateLimiters.js";
|
import { gameLimiter } from "../middleware/rateLimiters.js";
|
||||||
|
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
||||||
|
|
||||||
export const gameRouter: Router = express.Router();
|
export const createGameRouter = (store: GameSessionStore): Router => {
|
||||||
|
const router = express.Router();
|
||||||
|
const controller = createGameController(store);
|
||||||
|
|
||||||
gameRouter.use(requireAuth);
|
router.use(requireAuth);
|
||||||
gameRouter.use(gameLimiter);
|
router.use(gameLimiter);
|
||||||
|
|
||||||
gameRouter.post("/start", createGame);
|
router.post("/start", controller.createGame);
|
||||||
gameRouter.post("/answer", submitAnswer);
|
router.post("/answer", controller.submitAnswer);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
||||||
|
|
||||||
import { getGameTerms, getDistractors } from "@lila/db";
|
import { getGameTerms, getDistractors } from "@lila/db";
|
||||||
import { createGameSession, evaluateAnswer } from "./gameService.js";
|
import { createGameSession, evaluateAnswer } from "./gameService.js";
|
||||||
|
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
|
||||||
|
|
||||||
const mockGetGameTerms = vi.mocked(getGameTerms);
|
const mockGetGameTerms = vi.mocked(getGameTerms);
|
||||||
const mockGetDistractors = vi.mocked(getDistractors);
|
const mockGetDistractors = vi.mocked(getDistractors);
|
||||||
|
|
@ -35,15 +36,22 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createGameSession", () => {
|
describe("createGameSession", () => {
|
||||||
|
|
||||||
|
let store: InMemoryGameSessionStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new InMemoryGameSessionStore();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a session with the correct number of questions", async () => {
|
it("returns a session with the correct number of questions", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
expect(session.sessionId).toBeDefined();
|
expect(session.sessionId).toBeDefined();
|
||||||
expect(session.questions).toHaveLength(3);
|
expect(session.questions).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each question has exactly 4 options", async () => {
|
it("each question has exactly 4 options", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
for (const question of session.questions) {
|
for (const question of session.questions) {
|
||||||
expect(question.options).toHaveLength(4);
|
expect(question.options).toHaveLength(4);
|
||||||
|
|
@ -51,14 +59,14 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each question has a unique questionId", async () => {
|
it("each question has a unique questionId", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
const ids = session.questions.map((q) => q.questionId);
|
const ids = session.questions.map((q) => q.questionId);
|
||||||
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("options have sequential optionIds 0-3", async () => {
|
it("options have sequential optionIds 0-3", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
for (const question of session.questions) {
|
for (const question of session.questions) {
|
||||||
const optionIds = question.options.map((o) => o.optionId);
|
const optionIds = question.options.map((o) => o.optionId);
|
||||||
|
|
@ -67,7 +75,7 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the correct answer is always among the options", async () => {
|
it("the correct answer is always among the options", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
for (let i = 0; i < session.questions.length; i++) {
|
for (let i = 0; i < session.questions.length; i++) {
|
||||||
const question = session.questions[i]!;
|
const question = session.questions[i]!;
|
||||||
|
|
@ -79,7 +87,7 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("distractors are never the correct answer", async () => {
|
it("distractors are never the correct answer", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
for (let i = 0; i < session.questions.length; i++) {
|
for (let i = 0; i < session.questions.length; i++) {
|
||||||
const question = session.questions[i]!;
|
const question = session.questions[i]!;
|
||||||
|
|
@ -95,7 +103,7 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets the prompt from the source text", async () => {
|
it("sets the prompt from the source text", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
expect(session.questions[0]!.prompt).toBe("dog");
|
expect(session.questions[0]!.prompt).toBe("dog");
|
||||||
expect(session.questions[1]!.prompt).toBe("cat");
|
expect(session.questions[1]!.prompt).toBe("cat");
|
||||||
|
|
@ -103,14 +111,14 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes gloss through (null or string)", async () => {
|
it("passes gloss through (null or string)", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
expect(session.questions[0]!.gloss).toBeNull();
|
expect(session.questions[0]!.gloss).toBeNull();
|
||||||
expect(session.questions[2]!.gloss).toBe("a building for living in");
|
expect(session.questions[2]!.gloss).toBe("a building for living in");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls getGameTerms with the correct arguments", async () => {
|
it("calls getGameTerms with the correct arguments", async () => {
|
||||||
await createGameSession(validRequest);
|
await createGameSession(validRequest, store);
|
||||||
|
|
||||||
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
||||||
"en",
|
"en",
|
||||||
|
|
@ -122,7 +130,7 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls getDistractors once per question", async () => {
|
it("calls getDistractors once per question", async () => {
|
||||||
await createGameSession(validRequest);
|
await createGameSession(validRequest, store);
|
||||||
|
|
||||||
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
@ -130,24 +138,35 @@ describe("createGameSession", () => {
|
||||||
it("propagates unexpected errors from getGameTerms", async () => {
|
it("propagates unexpected errors from getGameTerms", async () => {
|
||||||
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
|
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
|
||||||
|
|
||||||
await expect(createGameSession(validRequest)).rejects.toThrow(
|
await expect(createGameSession(validRequest, store)).rejects.toThrow(
|
||||||
"connection refused",
|
"connection refused",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("evaluateAnswer", () => {
|
describe("evaluateAnswer", () => {
|
||||||
|
|
||||||
|
let store: InMemoryGameSessionStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new InMemoryGameSessionStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
it("returns isCorrect: true when the correct option is selected", async () => {
|
it("returns isCorrect: true when the correct option is selected", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
const question = session.questions[0]!;
|
const question = session.questions[0]!;
|
||||||
const correctText = fakeTerms[0]!.targetText;
|
const correctText = fakeTerms[0]!.targetText;
|
||||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
const correctOption = question.options.find((o) => o.text === correctText)!;
|
||||||
|
|
||||||
const result = await evaluateAnswer({
|
const result = await evaluateAnswer(
|
||||||
sessionId: session.sessionId,
|
{
|
||||||
questionId: question.questionId,
|
sessionId: session.sessionId,
|
||||||
selectedOptionId: correctOption.optionId,
|
questionId: question.questionId,
|
||||||
});
|
selectedOptionId: correctOption.optionId,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.isCorrect).toBe(true);
|
expect(result.isCorrect).toBe(true);
|
||||||
expect(result.correctOptionId).toBe(correctOption.optionId);
|
expect(result.correctOptionId).toBe(correctOption.optionId);
|
||||||
|
|
@ -155,17 +174,20 @@ describe("evaluateAnswer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns isCorrect: false when a wrong option is selected", async () => {
|
it("returns isCorrect: false when a wrong option is selected", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
const question = session.questions[0]!;
|
const question = session.questions[0]!;
|
||||||
const correctText = fakeTerms[0]!.targetText;
|
const correctText = fakeTerms[0]!.targetText;
|
||||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
const correctOption = question.options.find((o) => o.text === correctText)!;
|
||||||
const wrongOption = question.options.find((o) => o.text !== correctText)!;
|
const wrongOption = question.options.find((o) => o.text !== correctText)!;
|
||||||
|
|
||||||
const result = await evaluateAnswer({
|
const result = await evaluateAnswer(
|
||||||
sessionId: session.sessionId,
|
{
|
||||||
questionId: question.questionId,
|
sessionId: session.sessionId,
|
||||||
selectedOptionId: wrongOption.optionId,
|
questionId: question.questionId,
|
||||||
});
|
selectedOptionId: wrongOption.optionId,
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.isCorrect).toBe(false);
|
expect(result.isCorrect).toBe(false);
|
||||||
expect(result.correctOptionId).toBe(correctOption.optionId);
|
expect(result.correctOptionId).toBe(correctOption.optionId);
|
||||||
|
|
@ -179,13 +201,13 @@ describe("evaluateAnswer", () => {
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
await expect(evaluateAnswer(submission, store)).rejects.toThrow(
|
||||||
"Game session not found",
|
"Game session not found",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws NotFoundError for a non-existent question", async () => {
|
it("throws NotFoundError for a non-existent question", async () => {
|
||||||
const session = await createGameSession(validRequest);
|
const session = await createGameSession(validRequest, store);
|
||||||
|
|
||||||
const submission: AnswerSubmission = {
|
const submission: AnswerSubmission = {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
|
@ -193,7 +215,7 @@ describe("evaluateAnswer", () => {
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
await expect(evaluateAnswer(submission, store)).rejects.toThrow(
|
||||||
"Question not found",
|
"Question not found",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,13 @@ import type {
|
||||||
AnswerSubmission,
|
AnswerSubmission,
|
||||||
AnswerResult,
|
AnswerResult,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
|
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
||||||
import { NotFoundError } from "../errors/AppError.js";
|
import { NotFoundError } from "../errors/AppError.js";
|
||||||
import { shuffleArray } from "../lib/utils.js";
|
import { shuffleArray } from "../lib/utils.js";
|
||||||
|
|
||||||
const gameSessionStore = new InMemoryGameSessionStore();
|
|
||||||
|
|
||||||
export const createGameSession = async (
|
export const createGameSession = async (
|
||||||
request: GameRequest,
|
request: GameRequest,
|
||||||
|
store: GameSessionStore,
|
||||||
): Promise<GameSession> => {
|
): Promise<GameSession> => {
|
||||||
const terms = await getGameTerms(
|
const terms = await getGameTerms(
|
||||||
request.source_language,
|
request.source_language,
|
||||||
|
|
@ -60,15 +59,16 @@ export const createGameSession = async (
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
await gameSessionStore.create(sessionId, { answers: answerKey });
|
await store.create(sessionId, { answers: answerKey });
|
||||||
|
|
||||||
return { sessionId, questions };
|
return { sessionId, questions };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const evaluateAnswer = async (
|
export const evaluateAnswer = async (
|
||||||
submission: AnswerSubmission,
|
submission: AnswerSubmission,
|
||||||
|
store: GameSessionStore,
|
||||||
): Promise<AnswerResult> => {
|
): Promise<AnswerResult> => {
|
||||||
const session = await gameSessionStore.get(submission.sessionId);
|
const session = await store.get(submission.sessionId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue