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(); 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 = {}; 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; 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 = {}) { 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, 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 }; 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); });