test(api): add unit and integration tests for game service and endpoints
- Unit tests for createGameSession and evaluateAnswer (14 tests) - Endpoint tests for POST /game/start and /game/answer via supertest (8 tests) - Mock @glossa/db — no real database dependency
This commit is contained in:
parent
48457936e8
commit
e320f43d8e
5 changed files with 559 additions and 21 deletions
|
|
@ -6,7 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@glossa/db": "workspace:*",
|
||||
|
|
@ -15,6 +16,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
apps/api/src/controllers/gameController.test.ts
Normal file
139
apps/api/src/controllers/gameController.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import request from "supertest";
|
||||
|
||||
vi.mock("@glossa/db", () => ({
|
||||
getGameTerms: vi.fn(),
|
||||
getDistractors: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getGameTerms, getDistractors } from "@glossa/db";
|
||||
import { createApp } from "../app.js";
|
||||
|
||||
const app = createApp();
|
||||
const mockGetGameTerms = vi.mocked(getGameTerms);
|
||||
const mockGetDistractors = vi.mocked(getDistractors);
|
||||
|
||||
const validBody = {
|
||||
source_language: "en",
|
||||
target_language: "it",
|
||||
pos: "noun",
|
||||
difficulty: "easy",
|
||||
rounds: "3",
|
||||
};
|
||||
|
||||
const fakeTerms = [
|
||||
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
||||
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
||||
{ termId: "t3", sourceText: "house", targetText: "casa", sourceGloss: null },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
||||
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
|
||||
});
|
||||
|
||||
describe("POST /api/v1/game/start", () => {
|
||||
it("returns 200 with a valid game session", async () => {
|
||||
const res = await request(app).post("/api/v1/game/start").send(validBody);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.sessionId).toBeDefined();
|
||||
expect(res.body.data.questions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns 400 when the body is empty", async () => {
|
||||
const res = await request(app).post("/api/v1/game/start").send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 400 when required fields are missing", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/v1/game/start")
|
||||
.send({ source_language: "en" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 400 when a field has an invalid value", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/v1/game/start")
|
||||
.send({ ...validBody, difficulty: "impossible" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/game/answer", () => {
|
||||
it("returns 200 with an answer result for a valid submission", async () => {
|
||||
// Start a game first
|
||||
const startRes = await request(app)
|
||||
.post("/api/v1/game/start")
|
||||
.send(validBody);
|
||||
|
||||
const { sessionId, questions } = startRes.body.data;
|
||||
const question = questions[0];
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/v1/game/answer")
|
||||
.send({
|
||||
sessionId,
|
||||
questionId: question.questionId,
|
||||
selectedOptionId: 0,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.data.questionId).toBe(question.questionId);
|
||||
expect(typeof res.body.data.isCorrect).toBe("boolean");
|
||||
expect(typeof res.body.data.correctOptionId).toBe("number");
|
||||
expect(res.body.data.selectedOptionId).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 400 when the body is empty", async () => {
|
||||
const res = await request(app).post("/api/v1/game/answer").send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 404 when the session does not exist", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/v1/game/answer")
|
||||
.send({
|
||||
sessionId: "00000000-0000-0000-0000-000000000000",
|
||||
questionId: "00000000-0000-0000-0000-000000000000",
|
||||
selectedOptionId: 0,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toContain("Game session not found");
|
||||
});
|
||||
|
||||
it("returns 404 when the question does not exist in the session", async () => {
|
||||
const startRes = await request(app)
|
||||
.post("/api/v1/game/start")
|
||||
.send(validBody);
|
||||
|
||||
const { sessionId } = startRes.body.data;
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/v1/game/answer")
|
||||
.send({
|
||||
sessionId,
|
||||
questionId: "00000000-0000-0000-0000-000000000000",
|
||||
selectedOptionId: 0,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toContain("Question not found");
|
||||
});
|
||||
});
|
||||
195
apps/api/src/services/gameService.test.ts
Normal file
195
apps/api/src/services/gameService.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { GameRequest, AnswerSubmission } from "@glossa/shared";
|
||||
|
||||
vi.mock("@glossa/db", () => ({
|
||||
getGameTerms: vi.fn(),
|
||||
getDistractors: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getGameTerms, getDistractors } from "@glossa/db";
|
||||
import { createGameSession, evaluateAnswer } from "./gameService.js";
|
||||
|
||||
const mockGetGameTerms = vi.mocked(getGameTerms);
|
||||
const mockGetDistractors = vi.mocked(getDistractors);
|
||||
|
||||
const validRequest: GameRequest = {
|
||||
source_language: "en",
|
||||
target_language: "it",
|
||||
pos: "noun",
|
||||
difficulty: "easy",
|
||||
rounds: "3",
|
||||
};
|
||||
|
||||
const fakeTerms = [
|
||||
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
||||
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
||||
{
|
||||
termId: "t3",
|
||||
sourceText: "house",
|
||||
targetText: "casa",
|
||||
sourceGloss: "a building for living in",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
||||
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
|
||||
});
|
||||
|
||||
describe("createGameSession", () => {
|
||||
it("returns a session with the correct number of questions", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
expect(session.sessionId).toBeDefined();
|
||||
expect(session.questions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("each question has exactly 4 options", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
for (const question of session.questions) {
|
||||
expect(question.options).toHaveLength(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("each question has a unique questionId", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
const ids = session.questions.map((q) => q.questionId);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("options have sequential optionIds 0-3", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
for (const question of session.questions) {
|
||||
const optionIds = question.options.map((o) => o.optionId);
|
||||
expect(optionIds).toEqual([0, 1, 2, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the correct answer is always among the options", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
for (let i = 0; i < session.questions.length; i++) {
|
||||
const question = session.questions[i]!;
|
||||
const correctText = fakeTerms[i]!.targetText;
|
||||
const optionTexts = question.options.map((o) => o.text);
|
||||
|
||||
expect(optionTexts).toContain(correctText);
|
||||
}
|
||||
});
|
||||
|
||||
it("distractors are never the correct answer", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
for (let i = 0; i < session.questions.length; i++) {
|
||||
const question = session.questions[i]!;
|
||||
const correctText = fakeTerms[i]!.targetText;
|
||||
const distractorTexts = question.options
|
||||
.map((o) => o.text)
|
||||
.filter((t) => t !== correctText);
|
||||
|
||||
for (const text of distractorTexts) {
|
||||
expect(text).not.toBe(correctText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sets the prompt from the source text", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
expect(session.questions[0]!.prompt).toBe("dog");
|
||||
expect(session.questions[1]!.prompt).toBe("cat");
|
||||
expect(session.questions[2]!.prompt).toBe("house");
|
||||
});
|
||||
|
||||
it("passes gloss through (null or string)", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
expect(session.questions[0]!.gloss).toBeNull();
|
||||
expect(session.questions[2]!.gloss).toBe("a building for living in");
|
||||
});
|
||||
|
||||
it("calls getGameTerms with the correct arguments", async () => {
|
||||
await createGameSession(validRequest);
|
||||
|
||||
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
||||
"en",
|
||||
"it",
|
||||
"noun",
|
||||
"easy",
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls getDistractors once per question", async () => {
|
||||
await createGameSession(validRequest);
|
||||
|
||||
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateAnswer", () => {
|
||||
it("returns isCorrect: true when the correct option is selected", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
const question = session.questions[0]!;
|
||||
const correctText = fakeTerms[0]!.targetText;
|
||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
||||
|
||||
const result = await evaluateAnswer({
|
||||
sessionId: session.sessionId,
|
||||
questionId: question.questionId,
|
||||
selectedOptionId: correctOption.optionId,
|
||||
});
|
||||
|
||||
expect(result.isCorrect).toBe(true);
|
||||
expect(result.correctOptionId).toBe(correctOption.optionId);
|
||||
expect(result.selectedOptionId).toBe(correctOption.optionId);
|
||||
});
|
||||
|
||||
it("returns isCorrect: false when a wrong option is selected", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
const question = session.questions[0]!;
|
||||
const correctText = fakeTerms[0]!.targetText;
|
||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
||||
const wrongOption = question.options.find((o) => o.text !== correctText)!;
|
||||
|
||||
const result = await evaluateAnswer({
|
||||
sessionId: session.sessionId,
|
||||
questionId: question.questionId,
|
||||
selectedOptionId: wrongOption.optionId,
|
||||
});
|
||||
|
||||
expect(result.isCorrect).toBe(false);
|
||||
expect(result.correctOptionId).toBe(correctOption.optionId);
|
||||
expect(result.selectedOptionId).toBe(wrongOption.optionId);
|
||||
});
|
||||
|
||||
it("throws NotFoundError for a non-existent session", async () => {
|
||||
const submission: AnswerSubmission = {
|
||||
sessionId: "00000000-0000-0000-0000-000000000000",
|
||||
questionId: "00000000-0000-0000-0000-000000000000",
|
||||
selectedOptionId: 0,
|
||||
};
|
||||
|
||||
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
||||
"Game session not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NotFoundError for a non-existent question", async () => {
|
||||
const session = await createGameSession(validRequest);
|
||||
|
||||
const submission: AnswerSubmission = {
|
||||
sessionId: session.sessionId,
|
||||
questionId: "00000000-0000-0000-0000-000000000000",
|
||||
selectedOptionId: 0,
|
||||
};
|
||||
|
||||
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
||||
"Question not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue