- 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
202 lines
5.4 KiB
TypeScript
202 lines
5.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("@lila/db", () => ({
|
|
createLobby: vi.fn(),
|
|
getLobbyByCodeWithPlayers: vi.fn(),
|
|
addPlayer: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
createLobby as createLobbyModel,
|
|
getLobbyByCodeWithPlayers,
|
|
addPlayer,
|
|
} from "@lila/db";
|
|
import { createLobby, joinLobby } from "./lobbyService.js";
|
|
|
|
const mockCreateLobby = vi.mocked(createLobbyModel);
|
|
const mockGetLobbyByCodeWithPlayers = vi.mocked(getLobbyByCodeWithPlayers);
|
|
const mockAddPlayer = vi.mocked(addPlayer);
|
|
|
|
const fakeLobby = {
|
|
id: "00000000-0000-4000-8000-000000000001",
|
|
code: "ABC123",
|
|
hostUserId: "user-1",
|
|
status: "waiting" as const,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
const fakeLobbyWithPlayers = {
|
|
...fakeLobby,
|
|
players: [
|
|
{
|
|
lobbyId: fakeLobby.id,
|
|
userId: "user-1",
|
|
score: 0,
|
|
joinedAt: new Date(),
|
|
user: { id: "user-1", name: "Alice" },
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockCreateLobby.mockResolvedValue(fakeLobby);
|
|
mockAddPlayer.mockResolvedValue({
|
|
lobbyId: fakeLobby.id,
|
|
userId: "user-1",
|
|
score: 0,
|
|
joinedAt: new Date(),
|
|
});
|
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue(fakeLobbyWithPlayers);
|
|
});
|
|
|
|
describe("createLobby", () => {
|
|
it("creates a lobby and adds the host as the first player", async () => {
|
|
const result = await createLobby("user-1");
|
|
|
|
expect(mockCreateLobby).toHaveBeenCalledOnce();
|
|
expect(mockAddPlayer).toHaveBeenCalledWith(
|
|
fakeLobby.id,
|
|
"user-1",
|
|
expect.any(Number),
|
|
);
|
|
expect(result.id).toBe(fakeLobby.id);
|
|
});
|
|
|
|
it("retries on unique code collision", async () => {
|
|
const uniqueViolation = Object.assign(new Error("unique"), {
|
|
code: "23505",
|
|
});
|
|
mockCreateLobby
|
|
.mockRejectedValueOnce(uniqueViolation)
|
|
.mockResolvedValueOnce(fakeLobby);
|
|
|
|
const result = await createLobby("user-1");
|
|
|
|
expect(mockCreateLobby).toHaveBeenCalledTimes(2);
|
|
expect(result.id).toBe(fakeLobby.id);
|
|
});
|
|
|
|
it("throws after max retry attempts", async () => {
|
|
const uniqueViolation = Object.assign(new Error("unique"), {
|
|
code: "23505",
|
|
});
|
|
mockCreateLobby.mockRejectedValue(uniqueViolation);
|
|
|
|
await expect(createLobby("user-1")).rejects.toThrow(
|
|
"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", () => {
|
|
it("returns lobby with players when join succeeds", async () => {
|
|
const fullLobby = {
|
|
...fakeLobbyWithPlayers,
|
|
players: [
|
|
...fakeLobbyWithPlayers.players,
|
|
{
|
|
lobbyId: fakeLobby.id,
|
|
userId: "user-2",
|
|
score: 0,
|
|
joinedAt: new Date(),
|
|
user: { id: "user-2", name: "Bob" },
|
|
},
|
|
],
|
|
};
|
|
mockGetLobbyByCodeWithPlayers
|
|
.mockResolvedValueOnce(fakeLobbyWithPlayers)
|
|
.mockResolvedValueOnce(fullLobby);
|
|
|
|
const result = await joinLobby("ABC123", "user-2");
|
|
|
|
expect(mockAddPlayer).toHaveBeenCalledWith(
|
|
fakeLobby.id,
|
|
"user-2",
|
|
expect.any(Number),
|
|
);
|
|
expect(result.players).toHaveLength(2);
|
|
});
|
|
|
|
it("throws NotFoundError when lobby does not exist", async () => {
|
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue(undefined);
|
|
|
|
await expect(joinLobby("XXXXXX", "user-2")).rejects.toThrow(
|
|
"Lobby not found",
|
|
);
|
|
});
|
|
|
|
it("throws ConflictError when lobby is not waiting", async () => {
|
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
|
...fakeLobbyWithPlayers,
|
|
status: "in_progress" as const,
|
|
});
|
|
|
|
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
|
|
"Game has already started",
|
|
);
|
|
});
|
|
|
|
it("returns lobby idempotently when user already joined", async () => {
|
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
|
...fakeLobbyWithPlayers,
|
|
players: [
|
|
{
|
|
lobbyId: fakeLobby.id,
|
|
userId: "user-1",
|
|
score: 0,
|
|
joinedAt: new Date(),
|
|
user: { id: "user-1", name: "Alice" },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await joinLobby("ABC123", "user-1");
|
|
|
|
expect(mockAddPlayer).not.toHaveBeenCalled();
|
|
expect(result.players).toHaveLength(1);
|
|
});
|
|
|
|
it("throws ConflictError when lobby is full", async () => {
|
|
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
|
...fakeLobbyWithPlayers,
|
|
players: Array.from({ length: 4 }, (_, i) => ({
|
|
lobbyId: fakeLobby.id,
|
|
userId: `user-${i}`,
|
|
score: 0,
|
|
joinedAt: new Date(),
|
|
user: { id: `user-${i}`, name: `Player ${i}` },
|
|
})),
|
|
});
|
|
|
|
await expect(joinLobby("ABC123", "user-5")).rejects.toThrow(
|
|
"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",
|
|
);
|
|
});
|
|
});
|