228 lines
7.6 KiB
JavaScript
228 lines
7.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
|
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
|
const db = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
process.env.SUPABASE_SECRET_KEY,
|
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
|
);
|
|
const rooms = [];
|
|
function player() {
|
|
let cookie;
|
|
return async (path, body, expected = 200) => {
|
|
const response = await fetch(`${origin}/api/v1/${path}`, {
|
|
method: body === undefined ? "GET" : "POST",
|
|
headers: {
|
|
...(body === undefined
|
|
? {}
|
|
: { origin, "content-type": "application/json" }),
|
|
...(cookie ? { cookie } : {}),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
if (response.headers.get("set-cookie"))
|
|
cookie = response.headers.get("set-cookie").split(";")[0];
|
|
const data = await response.json();
|
|
assert.equal(response.status, expected, JSON.stringify({ path, data }));
|
|
assert.match(response.headers.get("cache-control"), /no-store/);
|
|
return data;
|
|
};
|
|
}
|
|
try {
|
|
const host = player(),
|
|
friend = player(),
|
|
outsider = player();
|
|
const catalog = await host("catalog");
|
|
const datasetId = catalog.maps[0]?.id;
|
|
assert.ok(datasetId, "A published map is required");
|
|
const created = await host("rooms", {
|
|
datasetId,
|
|
difficulty: "hard",
|
|
displayName: "HTTP Host",
|
|
playerLimit: 2,
|
|
});
|
|
rooms.push(created.id);
|
|
const path = `rooms/${created.id}`;
|
|
const hostView = await host(path);
|
|
const preview = await outsider(path);
|
|
assert.equal(preview.you, null);
|
|
assert.deepEqual(preview.rounds, []);
|
|
await outsider(`${path}/media`, undefined, 403);
|
|
const joined = await friend(`${path}/join`, { displayName: "HTTP Friend" });
|
|
await outsider(`${path}/chat`, undefined, 403);
|
|
await outsider(
|
|
`${path}/chat`,
|
|
{ text: "Intruder", clientId: randomUUID() },
|
|
403,
|
|
);
|
|
const sent = {
|
|
text: "HTTP hello <b>plain text</b>",
|
|
clientId: randomUUID(),
|
|
name: "Forged",
|
|
};
|
|
const messages = (await host(`${path}/chat`, sent)).messages;
|
|
assert.equal(
|
|
messages[0].name,
|
|
"HTTP Host",
|
|
"sender name is server-controlled",
|
|
);
|
|
assert.equal(messages[0].playerId, hostView.you);
|
|
assert.equal((await host(`${path}/chat`, sent)).messages.length, 1);
|
|
assert.deepEqual((await friend(`${path}/chat`)).messages, messages);
|
|
await friend(`${path}/chat`, { text: " ", clientId: randomUUID() }, 400);
|
|
await friend(
|
|
`${path}/chat`,
|
|
{ text: "x".repeat(501), clientId: randomUUID() },
|
|
400,
|
|
);
|
|
await friend(
|
|
`${path}/chat`,
|
|
{ text: "x\u202e", clientId: randomUUID() },
|
|
400,
|
|
);
|
|
const concurrentMessages = await Promise.allSettled([
|
|
friend(`${path}/chat`, {
|
|
text: "First simultaneous send",
|
|
clientId: randomUUID(),
|
|
}),
|
|
friend(`${path}/chat`, {
|
|
text: "Second simultaneous send",
|
|
clientId: randomUUID(),
|
|
}),
|
|
]);
|
|
assert.equal(
|
|
concurrentMessages.filter((result) => result.status === "fulfilled").length,
|
|
1,
|
|
"only one simultaneous new message succeeds",
|
|
);
|
|
assert.match(
|
|
concurrentMessages.find((result) => result.status === "rejected").reason
|
|
.message,
|
|
/Too many chat messages/,
|
|
);
|
|
assert.equal(
|
|
(await friend(`${path}/join`, { displayName: "HTTP Other" })).you,
|
|
joined.you,
|
|
);
|
|
await outsider(`${path}/join`, { displayName: "Overflow" }, 400);
|
|
await friend(`${path}/start`, {}, 403);
|
|
await host(`${path}/start`, {});
|
|
let results;
|
|
for (let round = 0; round < 5; round++) {
|
|
const [hostMedia, friendMedia] = await Promise.all([
|
|
host(`${path}/media`),
|
|
friend(`${path}/media`),
|
|
]);
|
|
assert.equal(hostMedia.round, round);
|
|
assert.equal(friendMedia.round, round);
|
|
assert.deepEqual(
|
|
hostMedia.faces.map((url) => new URL(url).pathname),
|
|
friendMedia.faces.map((url) => new URL(url).pathname),
|
|
);
|
|
assert.equal(hostMedia.faces.length, 6);
|
|
if (round === 0)
|
|
for (const url of [hostMedia.map, hostMedia.faces[0]])
|
|
assert.equal((await fetch(url)).status, 200, "Signed media readable");
|
|
await host(`${path}/ready`, { round });
|
|
const playing = await friend(`${path}/ready`, { round });
|
|
assert.equal(playing.status, "playing");
|
|
const synchronized = await host(path);
|
|
assert.equal(
|
|
synchronized.rounds[round].deadline,
|
|
playing.rounds[round].deadline,
|
|
);
|
|
const pointResult = await db
|
|
.from("multiplayer_rounds")
|
|
.select("viewpoint")
|
|
.eq("room_id", created.id)
|
|
.eq("round_number", round)
|
|
.single();
|
|
if (pointResult.error) throw pointResult.error;
|
|
const point = pointResult.data.viewpoint;
|
|
const first = await host(`${path}/guess`, {
|
|
round,
|
|
x: point.x,
|
|
z: point.z,
|
|
});
|
|
assert.equal(first.status, "playing");
|
|
assert.deepEqual(first.rounds[round].results, []);
|
|
assert.equal(
|
|
(await host(path)).players.find((p) => p.id === hostView.you).submitted,
|
|
true,
|
|
"Reconnect preserves submitted guess",
|
|
);
|
|
await host(`${path}/guess`, {
|
|
round,
|
|
x: hostMedia.bounds.minX,
|
|
z: hostMedia.bounds.minZ,
|
|
});
|
|
const b = hostMedia.bounds;
|
|
const x = point.x - (b.minX + b.maxX) / 2 >= 0 ? b.minX : b.maxX;
|
|
const z = point.z - (b.minZ + b.maxZ) / 2 >= 0 ? b.minZ : b.maxZ;
|
|
results = await friend(`${path}/guess`, { round, x, z });
|
|
assert.equal(results.status, round === 4 ? "finished" : "results");
|
|
assert.equal(
|
|
results.rounds[round].results.find((r) => r.playerId === hostView.you)
|
|
.score,
|
|
5000,
|
|
);
|
|
if (round < 4) await host(`${path}/next`, { round });
|
|
}
|
|
assert.equal(results.players[0].id, hostView.you);
|
|
assert.equal(results.players[0].total, 25000);
|
|
assert.equal(
|
|
(await host(`${path}/chat`)).messages.length,
|
|
2,
|
|
"messages persist over five rounds",
|
|
);
|
|
await friend(`${path}/leave`, {});
|
|
await friend(`${path}/chat`, undefined, 403);
|
|
await friend(
|
|
`${path}/chat`,
|
|
{ text: "After leaving", clientId: randomUUID() },
|
|
403,
|
|
);
|
|
assert.equal(results.rounds.flatMap((round) => round.results).length, 10);
|
|
const capacityHost = player();
|
|
const full = await capacityHost("rooms", {
|
|
datasetId,
|
|
difficulty: "normal",
|
|
displayName: "Capacity Host",
|
|
playerLimit: 16,
|
|
});
|
|
rooms.push(full.id);
|
|
await Promise.all(
|
|
Array.from({ length: 14 }, (_, i) =>
|
|
player()(`rooms/${full.id}/join`, { displayName: `Capacity ${i + 2}` }),
|
|
),
|
|
);
|
|
const attempts = await Promise.allSettled([
|
|
player()(`rooms/${full.id}/join`, { displayName: "Last A" }),
|
|
player()(`rooms/${full.id}/join`, { displayName: "Last B" }),
|
|
]);
|
|
assert.equal(
|
|
attempts.filter((result) => result.status === "fulfilled").length,
|
|
1,
|
|
"Exactly one of the simultaneous last-slot joins succeeds",
|
|
);
|
|
const rejection = attempts.find((result) => result.status === "rejected");
|
|
assert.match(rejection.reason.message, /This room is full/);
|
|
assert.equal((await capacityHost(`rooms/${full.id}`)).playerCount, 16);
|
|
console.log(
|
|
"Multiplayer HTTP smoke passed: private room chat, trusted sender names, validation, concurrent-send throttling, retries, revoked membership, five shared rounds, signed media, and simultaneous 16-player capacity checks.",
|
|
);
|
|
} finally {
|
|
if (rooms.length) {
|
|
const { error } = await db
|
|
.from("multiplayer_rooms")
|
|
.delete()
|
|
.in("id", rooms);
|
|
if (error)
|
|
throw new Error(`Multiplayer test-room cleanup failed: ${error.message}`);
|
|
}
|
|
}
|