All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
490 lines
15 KiB
TypeScript
490 lines
15 KiB
TypeScript
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 }],
|
|
};
|
|
test("community and chat derive the actor from the signed-in account and reject forged fields", async () => {
|
|
const guest = fixture({ signedIn: false });
|
|
await assert.rejects(guest.run(`tournaments/${tournament}/chat`), /Sign in/);
|
|
const f = fixture();
|
|
await assert.rejects(
|
|
f.run(`tournaments/${tournament}/community`, "POST", {
|
|
action: "admin",
|
|
accountId: map,
|
|
actor: map,
|
|
}),
|
|
);
|
|
await assert.rejects(
|
|
f.run(`tournaments/${tournament}/chat`, "POST", {
|
|
clientId: room,
|
|
text: "hello",
|
|
accountId: map,
|
|
}),
|
|
);
|
|
f.reply("tournament_community", { moderator: true });
|
|
await f.run(`tournaments/${tournament}/community`, "POST", {
|
|
action: "ban",
|
|
accountId: map,
|
|
});
|
|
assert.equal(
|
|
(f.calls.at(-1)?.args[0] as { p_account: string }).p_account,
|
|
member,
|
|
);
|
|
f.reply("tournament_chat", { messages: [] });
|
|
await f.run(`tournaments/${tournament}/chat`, "POST", {
|
|
clientId: room,
|
|
text: "hello",
|
|
});
|
|
assert.equal(
|
|
(f.calls.at(-1)?.args[0] as { p_account: string }).p_account,
|
|
member,
|
|
);
|
|
});
|
|
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);
|
|
});
|