This commit is contained in:
Ryan Barlow
2026-05-25 18:58:07 +01:00
parent 08528b7cfa
commit 4cf234fb7a
14 changed files with 900 additions and 191 deletions
+112
View File
@@ -14,6 +14,7 @@ vi.mock("../../src/core/Schemas", async () => {
});
import { GameType } from "../../src/core/game/Game";
import { Client } from "../../src/server/Client";
import { GameServer } from "../../src/server/GameServer";
describe("GameLifecycle", () => {
@@ -86,3 +87,114 @@ describe("GameLifecycle", () => {
expect((game as any)._hasEnded).toBe(true);
});
});
describe("GameServer.rejoinClient — clanTag identityUpdate", () => {
let mockLogger: any;
const mkWs = (): any => ({
readyState: 1, // OPEN
on: vi.fn(),
send: vi.fn(),
close: vi.fn(),
removeAllListeners: vi.fn(),
});
beforeEach(() => {
mockLogger = {
child: vi.fn().mockReturnThis(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
});
const seedClient = (game: GameServer, clanTag: string | null) => {
const ws = mkWs();
const client = new Client(
"cid-1",
"pid-1",
null,
null,
undefined,
"127.0.0.1",
"tester",
clanTag,
ws,
undefined,
undefined,
[],
);
// Seed internals as if the client had joined normally.
(game as any).activeClients.push(client);
(game as any).allClients.set(client.clientID, client);
(game as any).persistentIdToClientId.set(
client.persistentID,
client.clientID,
);
(game as any).websockets.add(ws);
return client;
};
it("preserves clanTag on reconnect when identityUpdate omits it", () => {
const game = new GameServer("g-1", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
const newWs = mkWs();
const ok = game.rejoinClient(newWs as any, "pid-1", 0, {
username: "renamed",
});
expect(ok).toBe(true);
expect(client.clanTag).toBe("ABC");
expect(client.username).toBe("renamed");
});
it("clears clanTag on reconnect when identityUpdate passes null", () => {
const game = new GameServer("g-2", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
game.rejoinClient(mkWs() as any, "pid-1", 0, {
username: "tester",
clanTag: null,
});
expect(client.clanTag).toBeNull();
});
it("updates clanTag on reconnect when identityUpdate passes a new tag", () => {
const game = new GameServer("g-3", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
game.rejoinClient(mkWs() as any, "pid-1", 0, {
username: "tester",
clanTag: "XYZ",
});
expect(client.clanTag).toBe("XYZ");
});
it("does not change identity if the game has already started", () => {
const game = new GameServer("g-4", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
(game as any)._hasStarted = true;
game.rejoinClient(mkWs() as any, "pid-1", 0, {
username: "renamed",
clanTag: "XYZ",
});
expect(client.clanTag).toBe("ABC");
expect(client.username).toBe("tester");
});
});
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/server/ServerEnv", () => ({
ServerEnv: {
jwtIssuer: () => "http://auth.test",
apiKey: () => "test-key",
},
}));
vi.mock("../../src/server/Logger", () => ({
logger: {
child: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));
import {
_clearClanExistsCacheForTest,
clanExistsByTag,
} from "../../src/server/jwt";
const jsonResponse = (status: number, body: unknown = "") => ({
status,
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
});
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
_clearClanExistsCacheForTest();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("clanExistsByTag", () => {
it("returns true on HTTP 200", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(200))),
);
await expect(clanExistsByTag("ABC")).resolves.toBe(true);
});
it("returns false on HTTP 404", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(404))),
);
await expect(clanExistsByTag("XYZ")).resolves.toBe(false);
});
it("returns null and fails open on unexpected status (5xx)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(503))),
);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
});
it("returns null and fails open on rate-limit (429)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(429))),
);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
});
it("returns null on transport error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
});
it("caches results across calls within TTL", async () => {
const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200)));
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("ABC");
await clanExistsByTag("ABC");
await clanExistsByTag("ABC");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("does not cache fail-open (null) results so transient outages recover", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503))
.mockResolvedValueOnce(jsonResponse(200));
vi.stubGlobal("fetch", fetchSpy);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
await expect(clanExistsByTag("ABC")).resolves.toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("uppercases the tag in the URL", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(jsonResponse(200)),
);
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("abc");
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
expect(calledUrl).toContain("/public/clan/ABC/exists");
});
it("treats a body {exists:false} as false on 200 (forward-compat)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(200, { exists: false }))),
);
await expect(clanExistsByTag("ABC")).resolves.toBe(false);
});
it("caches by uppercased tag (different cases hit the same entry)", async () => {
const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200)));
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("abc");
await clanExistsByTag("ABC");
await clanExistsByTag("Abc");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("sends Accept: application/json header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(jsonResponse(200)),
);
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("ABC");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect((init.headers as Record<string, string>).Accept).toBe(
"application/json",
);
});
});