Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s

This commit is contained in:
2026-10-18 11:44:04 +05:00
parent ad73132f15
commit a7085e4e80
50 changed files with 4790 additions and 703 deletions

452
tests/tournaments.test.ts Normal file
View 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);
});