Compare commits

...

4 commits

Author SHA1 Message Date
lila
3971642848 Merge branch 'dev'
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m51s
2026-04-24 10:15:23 +02:00
lila
ee719aaa58 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.
2026-04-24 10:14:28 +02:00
lila
4ece995385 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
2026-04-24 10:11:36 +02:00
lila
762cf91f86 updating tasks 2026-04-24 09:30:20 +02:00
4 changed files with 160 additions and 3 deletions

View file

@ -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", () => {

View file

@ -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",
);
});
});

View file

@ -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",
);
});
});

View file

@ -26,9 +26,6 @@ Things that are actively in progress or should be picked up immediately. Mostly
- **Hetzner domain migration check** `[infra]`
Verify whether the lilastudy.com domain needs to be migrated following a Hetzner DNS change. Check Hetzner dashboard for any pending migration notice.
- **Security headers with helmet** `[security]`
Add helmet middleware to set secure HTTP response headers. One-liner: app.use(helmet()). Covers headers like X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy.
- **Conditionally register OAuth providers** `[debt]`
Better Auth logs warnings when social providers are registered without credentials (`Social provider google is missing clientId or clientSecret`). Instead of registering all providers unconditionally, only add a provider to the config when its credentials are present in the environment. Keeps local dev clean for contributors who don't have OAuth apps set up.
@ -120,6 +117,7 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
Shipped milestones, newest first.
- **04 - 2026 - Security headers with helmet** - Add helmet middleware to set secure HTTP response headers.
- **04 - 2026 - Rate limiting on API endpoints** - At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention)
- **04 - 2026 — Migrations in deploy pipeline** — Drizzle migrate runs as a CI/CD step before the API container restarts
- **04 - 2026 — Phase 6: Production deployment** — Hetzner VPS, Caddy HTTPS, Forgejo CI/CD, daily DB backups, cross-subdomain auth