WebSocket server: - WS auth via Better Auth session on upgrade request - Router with discriminated union dispatch and two-layer error handling - In-memory connections map with broadcastToLobby - Lobby handlers: join, leave, start - Game handlers: answer, resolve round, end game, game:ready for state sync - Shared game state store (LobbyGameStore interface + InMemory impl) - Timer map separate from store for Valkey-readiness REST API: - POST /api/v1/lobbies — create lobby + add host as first player - POST /api/v1/lobbies/:code/join — atomic join with capacity/status checks - getLobbyWithPlayers added to model for id-based lookup Frontend: - WsClient class with typed on/off, connect/disconnect, isConnected - WsProvider owns connection lifecycle (connect/disconnect/isConnected state) - WsConnector component triggers connection at multiplayer layout mount - Lobby waiting room: live player list, copyable code, host Start button - Game view: reuses QuestionCard, game:ready on mount, round results - MultiplayerScoreScreen: sorted scores, winner highlight, tie handling - Vite proxy: /ws and /api proxied to localhost:3000 for dev cookie fix Tests: - lobbyService.test.ts: create, join, retry, idempotency, full lobby - auth.test.ts: 401 reject, upgrade success, 500 on error - router.test.ts: dispatch all message types, error handling - vitest.config.ts: exclude dist folder Fixes: - server.ts: server.listen() instead of app.listen() for WS support - StrictMode removed from main.tsx (incompatible with WS lifecycle) - getLobbyWithPlayers(id) added for handleLobbyStart lookup
180 lines
5.7 KiB
TypeScript
180 lines
5.7 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 = [
|
|
{ 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);
|
|
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);
|
|
});
|
|
});
|
|
|
|
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 404 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(404);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain("Question not found");
|
|
});
|
|
});
|