lila/apps/api/src/controllers/gameController.test.ts
lila 963bff4eb8 feat: migrate production schema from OMW to Kaikki flat vocabulary model
- Replace terms/translations/term_glosses/term_examples with vocabulary_entries
  and entry_translations
- Remove decks, topics and related tables (deferred)
- Add cefr_level and difficulty to entry_translations for game query filtering
- Update termModel.ts for new schema — getDistractors now takes sourceLanguage
- Update gameService.ts and multiplayerGameService.ts for entryId rename
- Update all test fixtures from termId to entryId
- Generate and apply migration 0011
2026-05-05 17:39:25 +02:00

223 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
import type { GameSession, AnswerResult } from "@lila/shared";
type SuccessResponse<T> = { success: true; data: T };
type ErrorResponse = { success: false; error: string };
type GameStartResponse = SuccessResponse<GameSession>;
type GameAnswerResponse = SuccessResponse<AnswerResult>;
vi.mock("@lila/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@lila/db")>();
return { ...actual, getGameTerms: vi.fn(), getDistractors: vi.fn() };
});
vi.mock("../lib/auth.js", () => ({
auth: {
api: {
getSession: vi
.fn()
.mockResolvedValue({
session: {
id: "session-1",
userId: "user-1",
token: "fake-token",
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
createdAt: new Date(),
updatedAt: new Date(),
ipAddress: null,
userAgent: null,
},
user: {
id: "user-1",
name: "Test User",
email: "test@test.com",
emailVerified: false,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
}),
},
handler: vi.fn(),
},
}));
vi.mock("better-auth/node", () => ({
fromNodeHeaders: vi.fn().mockReturnValue({}),
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));
import { getGameTerms, getDistractors } from "@lila/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 = [
{ entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
{ entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
{
entryId: "t3",
sourceText: "house",
targetText: "casa",
sourceGloss: "a building for living in",
},
];
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);
const body = res.body as GameStartResponse;
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.sessionId).toBeDefined();
expect(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({});
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.success).toBe(false);
expect(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" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(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" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.success).toBe(false);
});
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(422);
expect(body.success).toBe(false);
});
it("returns a sanitised error message when the body is invalid", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, difficulty: "impossible" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.error).toBe("Invalid game settings");
expect(body.error).not.toContain("Invalid literal value");
expect(body.error).not.toContain("Invalid enum value");
});
});
describe("POST /api/v1/game/answer", () => {
it("returns 200 with an answer result for a valid submission", async () => {
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startBody.data;
const question = questions[0]!;
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: question.questionId,
selectedOptionId: 0,
});
const body = res.body as GameAnswerResponse;
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.questionId).toBe(question.questionId);
expect(typeof body.data.isCorrect).toBe("boolean");
expect(typeof body.data.correctOptionId).toBe("number");
expect(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({});
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(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,
});
const body = res.body as ErrorResponse;
expect(res.status).toBe(404);
expect(body.success).toBe(false);
expect(body.error).toContain("Game session not found");
});
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);
const startBody = startRes.body as GameStartResponse;
const { sessionId } = startBody.data;
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
});
const body = res.body as ErrorResponse;
expect(res.status).toBe(409);
expect(body.success).toBe(false);
expect(body.error).toContain("Question already answered");
});
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" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.success).toBe(false);
});
it("returns 400 when rounds has an invalid value", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, rounds: "invalid" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.success).toBe(false);
});
});