diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 66b7470..d1453f4 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -126,14 +126,6 @@ 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 c5de043..c998c12 100644 --- a/apps/api/src/services/lobbyService.test.ts +++ b/apps/api/src/services/lobbyService.test.ts @@ -87,14 +87,6 @@ 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", () => { @@ -181,22 +173,4 @@ 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", - ); - }); }); diff --git a/apps/api/src/services/multiplayerGameService.test.ts b/apps/api/src/services/multiplayerGameService.test.ts deleted file mode 100644 index 2261960..0000000 --- a/apps/api/src/services/multiplayerGameService.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -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", - ); - }); -}); diff --git a/documentation/backlog.md b/documentation/backlog.md index 127ee05..7656309 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -26,6 +26,9 @@ 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. @@ -117,7 +120,6 @@ 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