Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
452
tests/tournaments.test.ts
Normal file
452
tests/tournaments.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { NextRequest } from "next/server";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../shared/database";
|
||||
import { tournamentApi } from "../lib/tournament-api";
|
||||
import {
|
||||
serverLocalToIso,
|
||||
tournamentSettings,
|
||||
tournamentEdit,
|
||||
} from "../shared/tournaments";
|
||||
import { ServiceError } from "../lib/service-error";
|
||||
|
||||
const tournament = "11111111-1111-4111-8111-111111111111";
|
||||
const member = "22222222-2222-4222-8222-222222222222";
|
||||
const map = "33333333-3333-4333-8333-333333333333";
|
||||
const room = "44444444-4444-4444-8444-444444444444";
|
||||
const revision = "55555555-5555-4555-8555-555555555555";
|
||||
const now = Date.parse("2026-10-18T12:00:00.000Z");
|
||||
const valid = {
|
||||
name: "Sunday expedition",
|
||||
datasetId: map,
|
||||
difficulty: "normal",
|
||||
playerLimit: 32,
|
||||
roundCount: 10,
|
||||
roundTimeSeconds: 300,
|
||||
startsAt: "2026-10-19T17:00:00+05:00",
|
||||
announceDiscord: true,
|
||||
};
|
||||
type Result = {
|
||||
data: unknown;
|
||||
error: { message: string; code?: string } | null;
|
||||
};
|
||||
function fixture({ signedIn = true, inactive = false } = {}) {
|
||||
const calls: { table: string; method: string; args: unknown[] }[] = [];
|
||||
const replies = new Map<string, Result[]>();
|
||||
const rates: string[] = [],
|
||||
announcements: (string | undefined)[] = [];
|
||||
const signatures: (string | null)[][] = [];
|
||||
let bodyReads = 0;
|
||||
const take = (table: string) =>
|
||||
replies.get(table)?.shift() ?? { data: null, error: null };
|
||||
const db = {
|
||||
from(table: string) {
|
||||
const query: Record<string, unknown> = {};
|
||||
for (const method of ["select", "eq", "order", "limit", "single"])
|
||||
query[method] = (...args: unknown[]) => {
|
||||
calls.push({ table, method, args });
|
||||
return query;
|
||||
};
|
||||
query.then = (resolve: (value: Result) => unknown) =>
|
||||
Promise.resolve(take(table)).then(resolve);
|
||||
return query;
|
||||
},
|
||||
rpc(table: string, args: unknown) {
|
||||
calls.push({ table, method: "rpc", args: [args] });
|
||||
return Promise.resolve(take(table));
|
||||
},
|
||||
} as unknown as SupabaseClient<Database>;
|
||||
return {
|
||||
calls,
|
||||
rates,
|
||||
announcements,
|
||||
signatures,
|
||||
get bodyReads() {
|
||||
return bodyReads;
|
||||
},
|
||||
reply(table: string, data: unknown, error: Result["error"] = null) {
|
||||
const list = replies.get(table) ?? [];
|
||||
list.push({ data, error });
|
||||
replies.set(table, list);
|
||||
},
|
||||
run(path: string, method = "GET", body?: unknown) {
|
||||
return tournamentApi(
|
||||
new NextRequest("https://minegeo.test/api/v1/" + path, { method }),
|
||||
path.split("?")[0].split("/"),
|
||||
{
|
||||
db,
|
||||
account: async () => {
|
||||
if (inactive) throw new ServiceError("Account banned", 403);
|
||||
return signedIn ? { id: member } : null;
|
||||
},
|
||||
jsonBody: async () => {
|
||||
bodyReads++;
|
||||
return body;
|
||||
},
|
||||
limit: async (key) => {
|
||||
rates.push(key);
|
||||
},
|
||||
mediaUrls: async (paths) => {
|
||||
signatures.push(paths);
|
||||
return paths.map((path) =>
|
||||
path ? "https://media.test/" + path : null,
|
||||
);
|
||||
},
|
||||
announce: (target) => {
|
||||
announcements.push(target);
|
||||
},
|
||||
serverTimezone: "Asia/Yekaterinburg",
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
function playingView(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: tournament,
|
||||
currentRound: 2,
|
||||
you: member,
|
||||
currentRoom: { id: room, you: member, status: "playing", round: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const manifest = {
|
||||
format: "minegeo-dataset/v1",
|
||||
minecraftVersion: "1.21.11",
|
||||
dimension: "minecraft:overworld",
|
||||
source: "plugin",
|
||||
name: "Fixture map",
|
||||
serverName: "Fixture server",
|
||||
north: "-z",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i,
|
||||
y: 64,
|
||||
z: i,
|
||||
})),
|
||||
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 }],
|
||||
};
|
||||
function mediaReplies(f: ReturnType<typeof fixture>, view = playingView()) {
|
||||
f.reply("tournament_action", view);
|
||||
f.reply("multiplayer_rooms", { revision_id: revision });
|
||||
f.reply("multiplayer_rounds", { viewpoint: manifest.viewpoints[3] });
|
||||
f.reply("revisions", { asset_root: map, manifest });
|
||||
}
|
||||
|
||||
test("tournament limits include the creator, bound both round tiers, and keep timer modes explicit", () => {
|
||||
assert.equal(tournamentSettings.parse(valid).timerMode, "shared");
|
||||
for (const playerLimit of [2, 32])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, playerLimit }).success,
|
||||
true,
|
||||
);
|
||||
for (const playerLimit of [1, 33, 2.5, "32", null])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, playerLimit }).success,
|
||||
false,
|
||||
);
|
||||
for (const roundCount of [1, 10])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, roundCount }).success,
|
||||
true,
|
||||
);
|
||||
for (const roundCount of [0, 11, 1.5])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, roundCount }).success,
|
||||
false,
|
||||
);
|
||||
for (const roundTimeSeconds of [15, 600, 3000])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, roundTimeSeconds }).success,
|
||||
true,
|
||||
);
|
||||
for (const roundTimeSeconds of [0, 14, 16, 3001, 15.5])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, roundTimeSeconds }).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({
|
||||
...valid,
|
||||
timerMode: "perMini",
|
||||
roundTimeSeconds: 600,
|
||||
}).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({
|
||||
...valid,
|
||||
timerMode: "perMini",
|
||||
roundTimeSeconds: 615,
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, timerMode: "unknown" }).success,
|
||||
false,
|
||||
);
|
||||
for (const extra of [
|
||||
{ ownerId: map },
|
||||
{ owner_id: map },
|
||||
{ score: 25000 },
|
||||
{ serverTimezone: "UTC" },
|
||||
{ rounds: 99 },
|
||||
])
|
||||
assert.equal(
|
||||
tournamentSettings.safeParse({ ...valid, ...extra }).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
tournamentEdit.safeParse({
|
||||
startsAt: valid.startsAt,
|
||||
playerLimit: 32,
|
||||
announceDiscord: true,
|
||||
ownerId: map,
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("server local start times depend only on the server zone and represent the same instant", () => {
|
||||
assert.equal(
|
||||
serverLocalToIso("2026-10-18T17:00", "Asia/Yekaterinburg"),
|
||||
"2026-10-18T12:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
serverLocalToIso("2026-10-18T12:00", "UTC"),
|
||||
"2026-10-18T12:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
serverLocalToIso("2026-10-18T17:45", "Asia/Kathmandu"),
|
||||
"2026-10-18T12:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
serverLocalToIso("2026-07-18T08:00", "America/New_York"),
|
||||
"2026-07-18T12:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
serverLocalToIso("2026-01-18T07:00", "America/New_York"),
|
||||
"2026-01-18T12:00:00.000Z",
|
||||
);
|
||||
for (const value of [
|
||||
"2026-02-30T12:00",
|
||||
"2026-02-29T12:00",
|
||||
"2026-13-01T12:00",
|
||||
"2026-10-18T24:00",
|
||||
"not-a-date",
|
||||
"2026-10-18",
|
||||
])
|
||||
assert.throws(() => serverLocalToIso(value, "UTC"));
|
||||
assert.throws(
|
||||
() => serverLocalToIso("2026-03-08T02:30", "America/New_York"),
|
||||
/does not exist/,
|
||||
);
|
||||
assert.throws(() => serverLocalToIso("2026-10-18T12:00", "Invalid/Timezone"));
|
||||
});
|
||||
|
||||
test("guests cannot create, join, or play and banned account errors propagate before writes", async () => {
|
||||
for (const [path, method, body] of [
|
||||
["tournaments", "POST", valid],
|
||||
[`tournaments/${tournament}/join`, "POST", {}],
|
||||
[
|
||||
`tournaments/${tournament}/ready`,
|
||||
"POST",
|
||||
{ round: 0, tournamentRound: 0 },
|
||||
],
|
||||
[
|
||||
`tournaments/${tournament}/guess`,
|
||||
"POST",
|
||||
{ round: 0, tournamentRound: 0, x: 1, z: 1 },
|
||||
],
|
||||
[`tournaments/${tournament}/media?tournamentRound=2`, "GET", undefined],
|
||||
] as const) {
|
||||
const f = fixture({ signedIn: false });
|
||||
await assert.rejects(
|
||||
f.run(path, method, body),
|
||||
(e) => e instanceof ServiceError && e.status === 401,
|
||||
);
|
||||
assert.equal(f.calls.length, 0);
|
||||
assert.equal(f.bodyReads, 0);
|
||||
}
|
||||
const inactive = fixture({ inactive: true });
|
||||
await assert.rejects(
|
||||
inactive.run("tournaments", "POST", valid),
|
||||
/Account banned/,
|
||||
);
|
||||
assert.equal(inactive.calls.length, 0);
|
||||
});
|
||||
|
||||
test("creation derives the owner and timezone from trusted dependencies and rate-limits that account", async (context) => {
|
||||
context.mock.method(Date, "now", () => now);
|
||||
const f = fixture();
|
||||
f.reply("create_tournament", tournament);
|
||||
const response = await f.run("tournaments", "POST", valid);
|
||||
assert.deepEqual(await response.json(), { id: tournament });
|
||||
const args = f.calls.find((call) => call.table === "create_tournament")!
|
||||
.args[0] as { p_owner: string; p_payload: Record<string, unknown> };
|
||||
assert.equal(args.p_owner, member);
|
||||
assert.equal(args.p_payload.serverTimezone, "Asia/Yekaterinburg");
|
||||
assert.equal(args.p_payload.timerMode, "shared");
|
||||
assert.equal(
|
||||
Date.parse(args.p_payload.startsAt as string),
|
||||
Date.parse("2026-10-19T12:00:00Z"),
|
||||
);
|
||||
assert.deepEqual(f.rates, ["tournaments:create:" + member]);
|
||||
assert.deepEqual(f.announcements, [tournament]);
|
||||
assert.equal(response.headers.get("Cache-Control"), "no-store");
|
||||
for (const extra of [
|
||||
{ ownerId: map },
|
||||
{ owner_id: map },
|
||||
{ score: 5000 },
|
||||
{ serverTimezone: "UTC" },
|
||||
]) {
|
||||
const rejected = fixture();
|
||||
await assert.rejects(
|
||||
rejected.run("tournaments", "POST", { ...valid, ...extra }),
|
||||
);
|
||||
assert.equal(rejected.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("schedules must be future instants no further than ninety days away", async (context) => {
|
||||
context.mock.method(Date, "now", () => now);
|
||||
for (const start of [now - 1, now, now + 90 * 86400000 + 1]) {
|
||||
const f = fixture();
|
||||
await assert.rejects(
|
||||
f.run("tournaments", "POST", {
|
||||
...valid,
|
||||
startsAt: new Date(start).toISOString(),
|
||||
}),
|
||||
(e) => e instanceof ServiceError && e.status === 400,
|
||||
);
|
||||
assert.equal(f.calls.length, 0);
|
||||
}
|
||||
const edge = fixture();
|
||||
edge.reply("create_tournament", tournament);
|
||||
assert.equal(
|
||||
(
|
||||
await edge.run("tournaments", "POST", {
|
||||
...valid,
|
||||
startsAt: new Date(now + 90 * 86400000).toISOString(),
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("game actions require both round indices and reject forged scores and unknown fields", async () => {
|
||||
for (const action of ["ready", "guess", "next"]) {
|
||||
const base = {
|
||||
round: 4,
|
||||
tournamentRound: 9,
|
||||
...(action === "guess" ? { x: 12, z: 21 } : {}),
|
||||
};
|
||||
const f = fixture();
|
||||
f.reply("tournament_action", playingView());
|
||||
await f.run(`tournaments/${tournament}/${action}`, "POST", base);
|
||||
const args = f.calls.find((call) => call.table === "tournament_action")!
|
||||
.args[0] as { p_account: string; p_action: string; p_payload: unknown };
|
||||
assert.equal(args.p_account, member);
|
||||
assert.equal(args.p_action, action);
|
||||
assert.deepEqual(args.p_payload, base);
|
||||
const { tournamentRound: _omitted, ...missing } = base;
|
||||
for (const body of [
|
||||
missing,
|
||||
{ ...base, tournamentRound: -1 },
|
||||
{ ...base, tournamentRound: 10 },
|
||||
{ ...base, tournamentRound: "2" },
|
||||
{ ...base, round: 5 },
|
||||
{ ...base, score: 5000 },
|
||||
{ ...base, playerId: map },
|
||||
...(action === "guess"
|
||||
? [
|
||||
{ ...base, x: Infinity },
|
||||
{ ...base, z: NaN },
|
||||
]
|
||||
: []),
|
||||
]) {
|
||||
const rejected = fixture();
|
||||
await assert.rejects(
|
||||
rejected.run(`tournaments/${tournament}/${action}`, "POST", body),
|
||||
);
|
||||
assert.equal(rejected.calls.length, 0);
|
||||
}
|
||||
}
|
||||
for (const action of ["join", "leave", "cancel", "start"]) {
|
||||
const f = fixture();
|
||||
await assert.rejects(
|
||||
f.run(`tournaments/${tournament}/${action}`, "POST", { ownerId: map }),
|
||||
);
|
||||
assert.equal(f.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("spectators and unavailable rooms cannot sign panorama media", async () => {
|
||||
for (const currentRoom of [
|
||||
null,
|
||||
{ id: room, you: null, status: "playing", round: 0 },
|
||||
{ id: room, you: member, status: "lobby", round: 0 },
|
||||
]) {
|
||||
const f = fixture();
|
||||
f.reply("tournament_action", playingView({ you: null, currentRoom }));
|
||||
await assert.rejects(
|
||||
f.run(`tournaments/${tournament}/media?tournamentRound=2`),
|
||||
(e) => e instanceof ServiceError && e.status === 409,
|
||||
);
|
||||
assert.equal(f.signatures.length, 0);
|
||||
assert.equal(
|
||||
f.calls.filter((call) => call.table !== "tournament_action").length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("media requires the current tournament round and signs only the current mini-round", async () => {
|
||||
for (const query of [
|
||||
"",
|
||||
"?tournamentRound=1",
|
||||
"?tournamentRound=3",
|
||||
"?tournamentRound=-1",
|
||||
"?tournamentRound=10",
|
||||
"?tournamentRound=2.5",
|
||||
]) {
|
||||
const f = fixture();
|
||||
mediaReplies(f);
|
||||
await assert.rejects(f.run(`tournaments/${tournament}/media${query}`));
|
||||
assert.equal(f.signatures.length, 0);
|
||||
}
|
||||
const f = fixture();
|
||||
mediaReplies(f);
|
||||
const response = await f.run(
|
||||
`tournaments/${tournament}/media?tournamentRound=2`,
|
||||
);
|
||||
const body = await response.json();
|
||||
assert.equal(body.round, 3);
|
||||
assert.equal(body.faces.length, 6);
|
||||
assert.equal(f.signatures.length, 1);
|
||||
assert.ok(
|
||||
f.signatures[0].slice(1).every((path) => path?.startsWith(map + "/p3/")),
|
||||
);
|
||||
assert.ok(
|
||||
f.calls.some(
|
||||
(call) =>
|
||||
call.table === "multiplayer_rounds" &&
|
||||
call.method === "eq" &&
|
||||
call.args[0] === "round_number" &&
|
||||
call.args[1] === 3,
|
||||
),
|
||||
);
|
||||
assert.equal(JSON.stringify(body).includes("viewpoint"), false);
|
||||
assert.equal(response.headers.get("Cache-Control"), "no-store");
|
||||
});
|
||||
|
||||
test("a blank media round cannot be coerced into tournament round zero", async () => {
|
||||
const f = fixture();
|
||||
mediaReplies(f, playingView({ currentRound: 0 }));
|
||||
await assert.rejects(
|
||||
f.run(`tournaments/${tournament}/media?tournamentRound=`),
|
||||
);
|
||||
assert.equal(f.signatures.length, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user