fix(lint): resolve all eslint errors across monorepo

- Type response bodies in gameController.test.ts to fix no-unsafe-member-access
- Replace async methods with Promise.resolve() in InMemoryGameSessionStore
  and InMemoryLobbyGameStore to satisfy require-await rule
- Add argsIgnorePattern and varsIgnorePattern to eslint config so
  underscore-prefixed params are globally ignored
- Fix no-misused-promises in ws/index.ts, lobbyHandlers, gameHandlers,
  __root.tsx, login.tsx and play.tsx by using void + .catch()
- Fix no-floating-promises on navigate calls in login.tsx
- Move API_URL outside Play component to fix useCallback dependency warning
- Type fetch response bodies in play.tsx to fix no-unsafe-assignment
- Add only-throw-error: off for route files (TanStack Router throw redirect)
- Remove unused WebSocket import from express.d.ts
- Fix unsafe return in connections.ts by typing empty Map constructor
- Exclude scripts/ folder from eslint
- Add targeted override for better-auth auth-client.ts (upstream typing issue)
This commit is contained in:
lila 2026-04-17 16:46:33 +02:00
parent a6d8ddec3b
commit ce19740cc8
12 changed files with 160 additions and 91 deletions

View file

@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest"; 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", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
@ -33,49 +39,48 @@ beforeEach(() => {
describe("POST /api/v1/game/start", () => { describe("POST /api/v1/game/start", () => {
it("returns 200 with a valid game session", async () => { it("returns 200 with a valid game session", async () => {
const res = await request(app).post("/api/v1/game/start").send(validBody); const res = await request(app).post("/api/v1/game/start").send(validBody);
const body = res.body as GameStartResponse;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.success).toBe(true); expect(body.success).toBe(true);
expect(res.body.data.sessionId).toBeDefined(); expect(body.data.sessionId).toBeDefined();
expect(res.body.data.questions).toHaveLength(3); expect(body.data.questions).toHaveLength(3);
}); });
it("returns 400 when the body is empty", async () => { it("returns 400 when the body is empty", async () => {
const res = await request(app).post("/api/v1/game/start").send({}); const res = await request(app).post("/api/v1/game/start").send({});
const body = res.body as ErrorResponse;
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.success).toBe(false); expect(body.success).toBe(false);
expect(res.body.error).toBeDefined(); expect(body.error).toBeDefined();
}); });
it("returns 400 when required fields are missing", async () => { it("returns 400 when required fields are missing", async () => {
const res = await request(app) const res = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")
.send({ source_language: "en" }); .send({ source_language: "en" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.success).toBe(false); expect(body.success).toBe(false);
}); });
it("returns 400 when a field has an invalid value", async () => { it("returns 400 when a field has an invalid value", async () => {
const res = await request(app) const res = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")
.send({ ...validBody, difficulty: "impossible" }); .send({ ...validBody, difficulty: "impossible" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.success).toBe(false); expect(body.success).toBe(false);
}); });
}); });
describe("POST /api/v1/game/answer", () => { describe("POST /api/v1/game/answer", () => {
it("returns 200 with an answer result for a valid submission", async () => { it("returns 200 with an answer result for a valid submission", async () => {
// Start a game first
const startRes = await request(app) const startRes = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")
.send(validBody); .send(validBody);
const startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startRes.body.data; const { sessionId, questions } = startBody.data;
const question = questions[0]; const question = questions[0]!;
const res = await request(app) const res = await request(app)
.post("/api/v1/game/answer") .post("/api/v1/game/answer")
@ -84,20 +89,20 @@ describe("POST /api/v1/game/answer", () => {
questionId: question.questionId, questionId: question.questionId,
selectedOptionId: 0, selectedOptionId: 0,
}); });
const body = res.body as GameAnswerResponse;
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.success).toBe(true); expect(body.success).toBe(true);
expect(res.body.data.questionId).toBe(question.questionId); expect(body.data.questionId).toBe(question.questionId);
expect(typeof res.body.data.isCorrect).toBe("boolean"); expect(typeof body.data.isCorrect).toBe("boolean");
expect(typeof res.body.data.correctOptionId).toBe("number"); expect(typeof body.data.correctOptionId).toBe("number");
expect(res.body.data.selectedOptionId).toBe(0); expect(body.data.selectedOptionId).toBe(0);
}); });
it("returns 400 when the body is empty", async () => { it("returns 400 when the body is empty", async () => {
const res = await request(app).post("/api/v1/game/answer").send({}); const res = await request(app).post("/api/v1/game/answer").send({});
const body = res.body as ErrorResponse;
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.success).toBe(false); expect(body.success).toBe(false);
}); });
it("returns 404 when the session does not exist", async () => { it("returns 404 when the session does not exist", async () => {
@ -108,18 +113,18 @@ describe("POST /api/v1/game/answer", () => {
questionId: "00000000-0000-0000-0000-000000000000", questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0, selectedOptionId: 0,
}); });
const body = res.body as ErrorResponse;
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body.success).toBe(false); expect(body.success).toBe(false);
expect(res.body.error).toContain("Game session not found"); expect(body.error).toContain("Game session not found");
}); });
it("returns 404 when the question does not exist in the session", async () => { it("returns 404 when the question does not exist in the session", async () => {
const startRes = await request(app) const startRes = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")
.send(validBody); .send(validBody);
const startBody = startRes.body as GameStartResponse;
const { sessionId } = startRes.body.data; const { sessionId } = startBody.data;
const res = await request(app) const res = await request(app)
.post("/api/v1/game/answer") .post("/api/v1/game/answer")
@ -128,9 +133,9 @@ describe("POST /api/v1/game/answer", () => {
questionId: "00000000-0000-0000-0000-000000000000", questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0, selectedOptionId: 0,
}); });
const body = res.body as ErrorResponse;
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(res.body.success).toBe(false); expect(body.success).toBe(false);
expect(res.body.error).toContain("Question not found"); expect(body.error).toContain("Question not found");
}); });
}); });

View file

@ -3,15 +3,17 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
export class InMemoryGameSessionStore implements GameSessionStore { export class InMemoryGameSessionStore implements GameSessionStore {
private sessions = new Map<string, GameSessionData>(); private sessions = new Map<string, GameSessionData>();
async create(sessionId: string, data: GameSessionData): Promise<void> { create(sessionId: string, data: GameSessionData): Promise<void> {
this.sessions.set(sessionId, data); this.sessions.set(sessionId, data);
return Promise.resolve();
} }
async get(sessionId: string): Promise<GameSessionData | null> { get(sessionId: string): Promise<GameSessionData | null> {
return this.sessions.get(sessionId) ?? null; return Promise.resolve(this.sessions.get(sessionId) ?? null);
} }
async delete(sessionId: string): Promise<void> { delete(sessionId: string): Promise<void> {
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
return Promise.resolve();
} }
} }

View file

@ -3,22 +3,25 @@ import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js";
export class InMemoryLobbyGameStore implements LobbyGameStore { export class InMemoryLobbyGameStore implements LobbyGameStore {
private games = new Map<string, LobbyGameData>(); private games = new Map<string, LobbyGameData>();
async create(lobbyId: string, data: LobbyGameData): Promise<void> { create(lobbyId: string, data: LobbyGameData): Promise<void> {
if (this.games.has(lobbyId)) { if (this.games.has(lobbyId)) {
throw new Error(`Game already exists for lobby: ${lobbyId}`); throw new Error(`Game already exists for lobby: ${lobbyId}`);
} }
this.games.set(lobbyId, data); this.games.set(lobbyId, data);
return Promise.resolve();
} }
async get(lobbyId: string): Promise<LobbyGameData | null> { get(lobbyId: string): Promise<LobbyGameData | null> {
return this.games.get(lobbyId) ?? null; return Promise.resolve(this.games.get(lobbyId) ?? null);
} }
async set(lobbyId: string, data: LobbyGameData): Promise<void> { set(lobbyId: string, data: LobbyGameData): Promise<void> {
this.games.set(lobbyId, data); this.games.set(lobbyId, data);
return Promise.resolve();
} }
async delete(lobbyId: string): Promise<void> { delete(lobbyId: string): Promise<void> {
this.games.delete(lobbyId); this.games.delete(lobbyId);
return Promise.resolve();
} }
} }

View file

@ -1,5 +1,4 @@
import type { Session, User } from "better-auth"; import type { Session, User } from "better-auth";
import type { WebSocket } from "ws";
declare global { declare global {
namespace Express { namespace Express {

View file

@ -24,7 +24,7 @@ export const removeConnection = (lobbyId: string, userId: string): void => {
}; };
export const getConnections = (lobbyId: string): Map<string, WebSocket> => { export const getConnections = (lobbyId: string): Map<string, WebSocket> => {
return connections.get(lobbyId) ?? new Map(); return connections.get(lobbyId) ?? new Map<string, WebSocket>();
}; };
export const broadcastToLobby = ( export const broadcastToLobby = (

View file

@ -126,7 +126,8 @@ export const resolveRound = async (
await endGame(lobbyId, state); await endGame(lobbyId, state);
} else { } else {
// Wait 3s then broadcast next question // Wait 3s then broadcast next question
setTimeout(async () => { setTimeout(() => {
void (async () => {
const fresh = await lobbyGameStore.get(lobbyId); const fresh = await lobbyGameStore.get(lobbyId);
if (!fresh) return; if (!fresh) return;
const nextQuestion = fresh.questions[fresh.currentIndex]; const nextQuestion = fresh.questions[fresh.currentIndex];
@ -143,10 +144,11 @@ export const resolveRound = async (
totalQuestions, totalQuestions,
}); });
// Restart timer for next round // Restart timer for next round
const timer = setTimeout(async () => { const timer = setTimeout(() => {
await resolveRound(lobbyId, fresh.currentIndex, totalQuestions); void resolveRound(lobbyId, fresh.currentIndex, totalQuestions);
}, 15000); }, 15000);
timers.set(lobbyId, timer); timers.set(lobbyId, timer);
})();
}, 3000); }, 3000);
} }
}; };

View file

@ -148,8 +148,10 @@ const startRoundTimer = (
questionIndex: number, questionIndex: number,
totalQuestions: number, totalQuestions: number,
): void => { ): void => {
const timer = setTimeout(async () => { const timer = setTimeout(() => {
await resolveRound(lobbyId, questionIndex, totalQuestions); void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => {
console.error("Error resolving round after timeout:", err);
});
}, 15000); }, 15000);
timers.set(lobbyId, timer); timers.set(lobbyId, timer);
}; };

View file

@ -15,18 +15,31 @@ export const setupWebSocket = (server: Server): WebSocketServer => {
socket.destroy(); socket.destroy();
return; return;
} }
handleUpgrade(request, socket, head, wss); void handleUpgrade(request, socket, head, wss).catch((err) => {
console.error("WebSocket upgrade error:", err);
socket.destroy();
});
}); });
wss.on( wss.on(
"connection", "connection",
(ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => { (ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => {
ws.on("message", (rawData) => { ws.on("message", (rawData) => {
handleMessage(ws, rawData, auth); void handleMessage(ws, rawData, auth).catch((err) => {
console.error(
`WebSocket message error for user ${auth.user.id}:`,
err,
);
});
}); });
ws.on("close", () => { ws.on("close", () => {
handleDisconnect(ws, auth); void handleDisconnect(ws, auth).catch((err) => {
console.error(
`WebSocket disconnect error for user ${auth.user.id}:`,
err,
);
});
}); });
ws.on("error", (err) => { ws.on("error", (err) => {

View file

@ -24,9 +24,13 @@ const RootLayout = () => {
{session ? ( {session ? (
<button <button
className="text-sm text-gray-600 hover:text-gray-900" className="text-sm text-gray-600 hover:text-gray-900"
onClick={async () => { onClick={() => {
void (async () => {
await signOut(); await signOut();
navigate({ to: "/" }); void navigate({ to: "/" });
})().catch((err) => {
console.error("Sign out error:", err);
});
}} }}
> >
Sign out ({session.user.name}) Sign out ({session.user.name})

View file

@ -8,7 +8,7 @@ const LoginPage = () => {
if (isPending) return <div className="p-4">Loading...</div>; if (isPending) return <div className="p-4">Loading...</div>;
if (session) { if (session) {
navigate({ to: "/" }); void navigate({ to: "/" });
return null; return null;
} }
@ -17,23 +17,25 @@ const LoginPage = () => {
<h1 className="text-2xl font-bold">sign in to lila</h1> <h1 className="text-2xl font-bold">sign in to lila</h1>
<button <button
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700" className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
onClick={() => onClick={() => {
signIn.social({ void signIn
provider: "github", .social({ provider: "github", callbackURL: window.location.origin })
callbackURL: window.location.origin, .catch((err) => {
}) console.error("GitHub sign in error:", err);
} });
}}
> >
Continue with GitHub Continue with GitHub
</button> </button>
<button <button
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500" className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
onClick={() => onClick={() => {
signIn.social({ void signIn
provider: "google", .social({ provider: "google", callbackURL: window.location.origin })
callbackURL: window.location.origin, .catch((err) => {
}) console.error("Google sign in error:", err);
} });
}}
> >
Continue with Google Continue with Google
</button> </button>

View file

@ -6,9 +6,12 @@ import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup"; import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client"; import { authClient } from "../lib/auth-client";
function Play() { type GameStartResponse = { success: true; data: GameSession };
const API_URL = import.meta.env["VITE_API_URL"] || ""; type GameAnswerResponse = { success: true; data: AnswerResult };
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null); const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@ -17,13 +20,15 @@ function Play() {
const startGame = useCallback(async (settings: GameRequest) => { const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true); setIsLoading(true);
const response = await fetch(`${API_URL}/api/v1/game/start`, { const response = await fetch(`${API_URL}/api/v1/game/start`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(settings), body: JSON.stringify(settings),
}); });
const data = await response.json();
const data = (await response.json()) as GameStartResponse;
setGameSession(data.data); setGameSession(data.data);
setCurrentQuestionIndex(0); setCurrentQuestionIndex(0);
setResults([]); setResults([]);
@ -55,7 +60,7 @@ function Play() {
selectedOptionId: optionId, selectedOptionId: optionId,
}), }),
}); });
const data = await response.json(); const data = (await response.json()) as GameAnswerResponse;
setCurrentResult(data.data); setCurrentResult(data.data);
}; };
@ -70,7 +75,13 @@ function Play() {
if (!gameSession && !isLoading) { if (!gameSession && !isLoading) {
return ( return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6"> <div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<GameSetup onStart={startGame} /> <GameSetup
onStart={(settings) => {
void startGame(settings).catch((err) => {
console.error("Start game error:", err);
});
}}
/>
</div> </div>
); );
} }
@ -99,11 +110,15 @@ function Play() {
return ( return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6"> <div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<QuestionCard <QuestionCard
onAnswer={(optionId) => {
void handleAnswer(optionId).catch((err) => {
console.error("Answer error:", err);
});
}}
question={question} question={question}
questionNumber={currentQuestionIndex + 1} questionNumber={currentQuestionIndex + 1}
totalQuestions={gameSession.questions.length} totalQuestions={gameSession.questions.length}
currentResult={currentResult} currentResult={currentResult}
onAnswer={handleAnswer}
onNext={handleNext} onNext={handleNext}
/> />
</div> </div>

View file

@ -13,6 +13,7 @@ export default defineConfig([
"eslint.config.mjs", "eslint.config.mjs",
"**/*.config.ts", "**/*.config.ts",
"routeTree.gen.ts", "routeTree.gen.ts",
"scripts/**",
]), ]),
eslint.configs.recommended, eslint.configs.recommended,
@ -38,6 +39,27 @@ export default defineConfig([
}, },
{ {
files: ["apps/web/src/routes/**/*.{ts,tsx}"], files: ["apps/web/src/routes/**/*.{ts,tsx}"],
rules: { "react-refresh/only-export-components": "off" }, rules: {
"react-refresh/only-export-components": "off",
"@typescript-eslint/only-throw-error": "off",
},
},
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
{
// better-auth's createAuthClient return type is insufficiently typed upstream.
// This is a known issue: https://github.com/better-auth/better-auth/issues
files: ["apps/web/src/lib/auth-client.ts"],
rules: { "@typescript-eslint/no-unsafe-assignment": "off" },
}, },
]); ]);