From 4ece9953855fa8a779c621cbaf74abf85f897a35 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 24 Apr 2026 10:11:36 +0200 Subject: [PATCH 1/2] test: fill coverage gaps in lobbyService and gameService - joinLobby: addPlayer returns falsy (race condition fallback) - joinLobby: lobby disappears between addPlayer and final fetch - createLobby: non-unique-violation errors re-thrown immediately - createGameSession: unexpected DB errors propagate correctly --- apps/api/src/services/gameService.test.ts | 8 +++++++ apps/api/src/services/lobbyService.test.ts | 26 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index d1453f4..66b7470 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -126,6 +126,14 @@ describe("createGameSession", () => { expect(mockGetDistractors).toHaveBeenCalledTimes(3); }); + + it("propagates unexpected errors from getGameTerms", async () => { + mockGetGameTerms.mockRejectedValue(new Error("connection refused")); + + await expect(createGameSession(validRequest)).rejects.toThrow( + "connection refused", + ); + }); }); describe("evaluateAnswer", () => { diff --git a/apps/api/src/services/lobbyService.test.ts b/apps/api/src/services/lobbyService.test.ts index c998c12..c5de043 100644 --- a/apps/api/src/services/lobbyService.test.ts +++ b/apps/api/src/services/lobbyService.test.ts @@ -87,6 +87,14 @@ describe("createLobby", () => { "Could not generate a unique lobby code", ); }); + + it("re-throws non-unique-violation errors immediately", async () => { + const dbError = new Error("connection refused"); + mockCreateLobby.mockRejectedValue(dbError); + + await expect(createLobby("user-1")).rejects.toThrow("connection refused"); + expect(mockCreateLobby).toHaveBeenCalledTimes(1); + }); }); describe("joinLobby", () => { @@ -173,4 +181,22 @@ describe("joinLobby", () => { "Lobby is full", ); }); + + it("throws ConflictError when addPlayer returns falsy (race condition)", async () => { + mockAddPlayer.mockResolvedValue(undefined); + + await expect(joinLobby("ABC123", "user-2")).rejects.toThrow( + "Lobby is no longer available", + ); + }); + + it("throws AppError when lobby disappears after addPlayer succeeds", async () => { + mockGetLobbyByCodeWithPlayers + .mockResolvedValueOnce(fakeLobbyWithPlayers) + .mockResolvedValueOnce(undefined); + + await expect(joinLobby("ABC123", "user-2")).rejects.toThrow( + "Lobby disappeared during join", + ); + }); }); From ee719aaa586feede2fe44a564b30c8804d3b2dc2 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 24 Apr 2026 10:14:28 +0200 Subject: [PATCH 2/2] test: add test file for multiplayerGameService Covers generateMultiplayerQuestions: question count, option structure, correct answer inclusion, correctOptionId integrity, prompt/gloss passthrough, DB call arguments, and error propagation. --- .../services/multiplayerGameService.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/api/src/services/multiplayerGameService.test.ts diff --git a/apps/api/src/services/multiplayerGameService.test.ts b/apps/api/src/services/multiplayerGameService.test.ts new file mode 100644 index 0000000..2261960 --- /dev/null +++ b/apps/api/src/services/multiplayerGameService.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); + +import { getGameTerms, getDistractors } from "@lila/db"; +import { generateMultiplayerQuestions } from "./multiplayerGameService.js"; + +const mockGetGameTerms = vi.mocked(getGameTerms); +const mockGetDistractors = vi.mocked(getDistractors); + +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("generateMultiplayerQuestions", () => { + it("returns the correct number of questions", async () => { + const questions = await generateMultiplayerQuestions(); + + expect(questions).toHaveLength(3); + }); + + it("each question has exactly 4 options", async () => { + const questions = await generateMultiplayerQuestions(); + + for (const question of questions) { + expect(question.options).toHaveLength(4); + } + }); + + it("each question has a unique questionId", async () => { + const questions = await generateMultiplayerQuestions(); + const ids = questions.map((q) => q.questionId); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it("options have sequential optionIds 0-3", async () => { + const questions = await generateMultiplayerQuestions(); + + for (const question of 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 questions = await generateMultiplayerQuestions(); + + for (let i = 0; i < questions.length; i++) { + const question = questions[i]!; + const correctText = fakeTerms[i]!.targetText; + const optionTexts = question.options.map((o) => o.text); + + expect(optionTexts).toContain(correctText); + } + }); + + it("correctOptionId points to the option whose text matches the correct answer", async () => { + const questions = await generateMultiplayerQuestions(); + + for (let i = 0; i < questions.length; i++) { + const question = questions[i]!; + const correctText = fakeTerms[i]!.targetText; + const correctOption = question.options.find( + (o) => o.optionId === question.correctOptionId, + ); + + expect(correctOption?.text).toBe(correctText); + } + }); + + it("sets the prompt from the source text", async () => { + const questions = await generateMultiplayerQuestions(); + + expect(questions[0]!.prompt).toBe("dog"); + expect(questions[1]!.prompt).toBe("cat"); + expect(questions[2]!.prompt).toBe("house"); + }); + + it("passes gloss through (null or string)", async () => { + const questions = await generateMultiplayerQuestions(); + + expect(questions[0]!.gloss).toBeNull(); + expect(questions[2]!.gloss).toBe("a building for living in"); + }); + + it("calls getGameTerms with the multiplayer defaults", async () => { + await generateMultiplayerQuestions(); + + expect(mockGetGameTerms).toHaveBeenCalledWith( + "en", + "it", + "noun", + "easy", + 3, + ); + }); + + it("calls getDistractors once per question", async () => { + await generateMultiplayerQuestions(); + + expect(mockGetDistractors).toHaveBeenCalledTimes(3); + }); + + it("propagates unexpected errors from getGameTerms", async () => { + mockGetGameTerms.mockRejectedValue(new Error("connection refused")); + + await expect(generateMultiplayerQuestions()).rejects.toThrow( + "connection refused", + ); + }); +});