Commit fcc74b9f authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: creating a tournament without a start time failed against production

el3ab_tournaments.starts_at is NOT NULL in the live schema. tournament-admin.php
allowed it to be null, so every create call that did not supply one was rejected
by Postgres. The local test schema had the column nullable, so the suite passed
and the defect only appeared when the rehearsal ran against production.

starts_at now defaults to now() — which also gives auto_start something to act on —
and tests/schema.sql marks the column NOT NULL so the next one is caught locally.

Adds tests/rehearsal.mjs, the script that found it: four throwaway accounts driven
through matchmaking, a 33-move game to checkmate and a full Swiss round against the
live deployment, with teardown in a finally block. Run it after any deploy.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 760f2e96
......@@ -80,7 +80,9 @@ function adminCreate($db, string $userId, array $input): void {
'rounds_total' => $rounds,
'min_players' => max(2, (int)($input['min_players'] ?? 4)),
'max_players' => max(2, (int)($input['max_players'] ?? 64)),
'starts_at' => $input['starts_at'] ?? null,
// starts_at is NOT NULL in production. Defaulting it to now keeps the
// create call usable without one, and auto_start then has a time to act on.
'starts_at' => $input['starts_at'] ?: gmdate('c'),
'status' => TOURNAMENT_STATUS_REGISTRATION,
'current_round' => 0,
'auto_start' => (bool)($input['auto_start'] ?? true),
......
/**
* Production rehearsal for EL3AB.
*
* Creates four throwaway guest accounts through the real auth endpoint, drives
* them through matchmaking, a complete chess game, and a full Swiss tournament
* round against the LIVE deployment, then deletes everything it created.
*
* Every request goes through the real API with a real Supabase token — no stubs.
*
* This WRITES TO PRODUCTION. It creates four accounts, a tournament and some
* matches, all tagged ZZZ-REHEARSAL-DELETE-ME, and removes them in a finally
* block that runs even when an assertion fails. Run it after a deploy:
*
* node tests/rehearsal.mjs
*/
import fs from 'node:fs';
const B = 'https://el3ab-player.caprover.al-arcade.com/api';
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const TAG = 'ZZZ-REHEARSAL-DELETE-ME';
const src = fs.readFileSync(new URL('../public/js/lib/chess.min.js', import.meta.url), 'utf8');
const mod = { exports: {} };
new Function('module', 'exports', 'globalThis', src)(mod, mod.exports, globalThis);
const Chess = globalThis.Chess || mod.exports.Chess || mod.exports;
const failures = [];
const created = { users: [], tournaments: [], matches: [] };
function check(label, cond, detail = '') {
if (cond) { console.log(` ok ${label}`); return true; }
failures.push(label);
console.log(` FAIL ${label}${detail ? ' — ' + detail : ''}`);
return false;
}
async function api(endpoint, body, token, method = 'POST') {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (method === 'POST') opts.body = JSON.stringify(body);
const res = await fetch(`${B}/${endpoint}`, opts);
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: { _raw: text.slice(0, 200) } }; }
}
const get = (endpoint, params, token) =>
api(`${endpoint}?${new URLSearchParams(params)}`, null, token, 'GET');
// Direct Supabase, used only for assertions and teardown.
async function sb(path, { method = 'GET', body } = {}) {
const res = await fetch(`${SB}/rest/v1/${path}`, {
method,
headers: { apikey: SK, Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: body ? JSON.stringify(body) : undefined,
});
const t = await res.text();
try { return JSON.parse(t); } catch { return t; }
}
async function teardown() {
console.log('\n=== teardown ===');
for (const tid of created.tournaments) {
await sb(`matches?tournament_id=eq.${tid}`, { method: 'DELETE' });
await sb(`el3ab_tournament_rounds?tournament_id=eq.${tid}`, { method: 'DELETE' });
await sb(`tournament_registrations?tournament_id=eq.${tid}`, { method: 'DELETE' });
await sb(`el3ab_tournaments?id=eq.${tid}`, { method: 'DELETE' });
}
console.log(` removed ${created.tournaments.length} tournament(s) and their rows`);
for (const m of created.matches) {
await sb(`mp_log?match_id=eq.${m}`, { method: 'DELETE' });
await sb(`matches?id=eq.${m}`, { method: 'DELETE' });
}
console.log(` removed ${created.matches.length} standalone match(es)`);
for (const u of created.users) {
await sb(`rating_history?player_id=eq.${u.id}`, { method: 'DELETE' });
await sb(`matchmaking_queue?player_id=eq.${u.id}`, { method: 'DELETE' });
await sb(`mp_log?player_id=eq.${u.id}`, { method: 'DELETE' });
await sb(`matches?white_player_id=eq.${u.id}`, { method: 'DELETE' });
await sb(`matches?black_player_id=eq.${u.id}`, { method: 'DELETE' });
await sb(`player_achievements?player_id=eq.${u.id}`, { method: 'DELETE' });
await sb(`profiles?id=eq.${u.id}`, { method: 'DELETE' });
await fetch(`${SB}/auth/v1/admin/users/${u.id}`, {
method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` },
});
}
console.log(` removed ${created.users.length} throwaway account(s)`);
}
// ---------------------------------------------------------------------------
try {
console.log('=== creating four throwaway accounts ===');
for (let i = 0; i < 4; i++) {
const r = await api('auth.php', { action: 'guest' });
if (!r.data.access_token || !r.data.profile?.id) {
console.log(' guest creation failed:', JSON.stringify(r.data).slice(0, 200));
break;
}
created.users.push({ id: r.data.profile.id, token: r.data.access_token });
await sb(`profiles?id=eq.${r.data.profile.id}`, {
method: 'PATCH',
body: { display_name: `${TAG} ${i + 1}`, elo_rapid: 1800 - i * 60, elo_blitz: 1750 - i * 55 },
});
}
check('four accounts created with real tokens', created.users.length === 4, `${created.users.length} created`);
if (created.users.length < 4) throw new Error('cannot continue without four accounts');
const P = created.users;
// -------------------------------------------------------------------------
console.log('\n=== 1. matchmaking, live ===');
let r = await api('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[0].token);
check('first player queues', r.data.queued === true, JSON.stringify(r.data));
r = await api('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[1].token);
const m1 = r.data;
check('second player is matched immediately', !!m1.match_id, JSON.stringify(m1));
if (m1.match_id) created.matches.push(m1.match_id);
r = await api('matchmaking.php', { action: 'status', game_key: 'chess' }, P[0].token);
check('the waiting player receives the same match', r.data.match_id === m1.match_id, `${r.data.match_id} vs ${m1.match_id}`);
check('THE TWO PLAYERS HAVE OPPOSITE COLOURS', r.data.color && m1.color && r.data.color !== m1.color,
`p1=${r.data.color} p2=${m1.color}`);
const c0 = (await api('game.php', { action: 'get', match_id: m1.match_id }, P[0].token)).data;
const c1 = (await api('game.php', { action: 'get', match_id: m1.match_id }, P[1].token)).data;
check('server tells each client its own colour', c0.my_color !== c1.my_color, `${c0.my_color} / ${c1.my_color}`);
check('and each is given the correct opponent', c0.opponent_id === P[1].id && c1.opponent_id === P[0].id);
const outsider = await api('game.php', { action: 'get', match_id: m1.match_id }, P[2].token);
check('a third party cannot read the live board', outsider.status === 403, `status ${outsider.status}`);
const white = c0.my_color === 'w' ? P[0] : P[1];
const black = white === P[0] ? P[1] : P[0];
// -------------------------------------------------------------------------
console.log('\n=== 2. a complete game, move by move, to checkmate ===');
const game = new Chess();
const OPERA = 'e4 e5 Nf3 d6 d4 Bg4 dxe5 Bxf3 Qxf3 dxe5 Bc4 Nf6 Qb3 Qe7 Nc3 c6 Bg5 b5 Nxb5 cxb5 Bxb5+ Nbd7 O-O-O Rd8 Rxd7 Rxd7 Rd1 Qe6 Bxd7+ Nxd7 Qb8+ Nxb8 Rd8#'.split(' ');
const history = [];
let mc = 0, rejected = 0, wrongTurnRefused = 0;
for (const san of OPERA) {
const mover = game.turn();
const m = game.move(san);
history.push({ from: m.from, to: m.to, san: m.san });
mc++;
// Every third move, the WRONG player tries it first.
if (mc % 3 === 0) {
const imposter = mover === 'w' ? black : white;
const bad = await api('game.php', {
action: 'move', match_id: m1.match_id, fen: game.fen(), move_count: mc,
move: JSON.stringify(history),
}, imposter.token);
if (bad.status === 409) wrongTurnRefused++;
}
const who = mover === 'w' ? white : black;
const res = await api('game.php', {
action: 'move', match_id: m1.match_id, fen: game.fen(), move_count: mc,
move: JSON.stringify(history),
}, who.token);
if (res.data.success !== true) { rejected++; if (rejected < 4) console.log(` move ${mc} ${san}: ${JSON.stringify(res.data)}`); }
}
check(`all ${OPERA.length} legal moves accepted by the live server`, rejected === 0, `${rejected} rejected`);
check('every out-of-turn attempt was refused', wrongTurnRefused === Math.floor(OPERA.length / 3),
`${wrongTurnRefused} of ${Math.floor(OPERA.length / 3)}`);
check('the position is checkmate', game.isCheckmate ? game.isCheckmate() : game.in_checkmate());
console.log('\n=== 3. the server decides the winner ===');
const loser = game.turn() === 'w' ? white : black;
const winner = loser === white ? black : white;
const eloBefore = (await sb(`profiles?id=eq.${winner.id}&select=elo_blitz`))[0].elo_blitz;
// The LOSER reports first, falsely claiming a win.
r = await api('game.php', {
action: 'complete', match_id: m1.match_id, reason: 'checkmate', result: 'win',
fen: game.fen(), pgn: game.pgn(),
}, loser.token);
check('a losing player cannot claim the win',
r.data.winner === (winner === white ? 'white' : 'black'), JSON.stringify(r.data));
const row = (await sb(`matches?id=eq.${m1.match_id}&select=status,result,metadata,rating_change_white,rating_change_black,white_rating_after`))[0];
check('the match is recorded as completed', row.status === 'completed', row.status);
check('the result is persisted', row.result === (winner === white ? 'white_wins' : 'black_wins'), String(row.result));
check('the reason is recorded', row.metadata?.end_reason === 'checkmate', JSON.stringify(row.metadata));
check('RATINGS WERE WRITTEN (never happened before today)',
row.rating_change_white !== null && row.white_rating_after !== null,
JSON.stringify({ w: row.rating_change_white, b: row.rating_change_black }));
const eloAfter = (await sb(`profiles?id=eq.${winner.id}&select=elo_blitz`))[0].elo_blitz;
check('the winner gained rating on their profile', eloAfter > eloBefore, `${eloBefore} -> ${eloAfter}`);
// -------------------------------------------------------------------------
console.log('\n=== 4. a full tournament ===');
r = await api('tournament-admin.php', {
action: 'create', name: TAG, format: 'swiss', time_control: 'blitz_3_0',
rounds: 2, min_players: 4, max_players: 8, auto_start: false, is_rated: false,
}, P[0].token);
const tid = r.data.tournament?.id;
check('tournament created', !!tid, JSON.stringify(r.data).slice(0, 200));
if (!tid) throw new Error('cannot continue without a tournament');
created.tournaments.push(tid);
for (const p of P) {
const rr = await api('tournaments.php', { action: 'register', tournament_id: tid }, p.token);
if (!rr.data.success) check(`register ${p.id.slice(0, 8)}`, false, JSON.stringify(rr.data));
}
const regs = await sb(`tournament_registrations?tournament_id=eq.${tid}&status=eq.registered&select=id`);
check('all four players registered', regs.length === 4, `${regs.length}`);
r = await api('tournament-admin.php', { action: 'start', tournament_id: tid }, P[0].token);
check('the tournament starts and pairs round 1', r.data.ok === true, JSON.stringify(r.data).slice(0, 200));
check('two boards for four players', r.data.round?.pairings?.length === 2, String(r.data.round?.pairings?.length));
const notAdmin = await api('tournament-admin.php', { action: 'start', tournament_id: tid }, P[1].token);
check('a non-organiser cannot start it', notAdmin.status === 403, `status ${notAdmin.status}`);
const pairings = (await get('swiss.php', { action: 'pairings', tournament_id: tid }, P[0].token)).data.pairings;
check('pairings are served with player names', pairings.length === 2 && !!pairings[0].white_name,
JSON.stringify(pairings.map(p => `${p.white_name} vs ${p.black_name}`)));
// Both paired players open their board at the same instant.
const board = pairings[0];
const wTok = P.find(p => p.id === board.white_id).token;
const bTok = P.find(p => p.id === board.black_id).token;
const [ja, jb] = await Promise.all([
api('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, wTok),
api('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, bTok),
]);
check('BOTH PLAYERS LAND ON THE SAME BOARD', ja.data.match_id === jb.data.match_id,
`${ja.data.match_id} vs ${jb.data.match_id}`);
check('with opposite colours', ja.data.color === 'w' && jb.data.color === 'b',
`${ja.data.color} / ${jb.data.color}`);
const nMatches = await sb(`matches?tournament_id=eq.${tid}&select=id`);
check('exactly one match row exists for that pairing', nMatches.length === 1, `${nMatches.length}`);
// Play both boards out.
for (const p of pairings) {
const wt = P.find(u => u.id === p.white_id).token;
const bt = P.find(u => u.id === p.black_id).token;
const j = await api('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, wt);
if (j.data.match_id) await api('game.php', { action: 'resign', match_id: j.data.match_id }, bt);
}
const t2 = (await sb(`el3ab_tournaments?id=eq.${tid}&select=status,current_round`))[0];
check('THE ROUND ADVANCED BY ITSELF once its last game finished',
t2.current_round === 2, JSON.stringify(t2));
const st = (await get('swiss.php', { action: 'standings', tournament_id: tid }, P[0].token)).data.standings;
check('standings are populated', st.length === 4, `${st.length} rows`);
check('the two winners lead on one point', st[0].points === 1 && st[1].points === 1,
JSON.stringify(st.map(s => `${s.name?.slice(-1)}:${s.points}`)));
check('tiebreaks are computed', typeof st[0].tiebreaks?.buchholz_cut_1 === 'number');
const r2 = (await get('swiss.php', { action: 'pairings', tournament_id: tid, round: 2 }, P[0].token)).data.pairings;
check('round 2 is paired and ready', r2.length === 2, `${r2.length} boards`);
const seen = new Set(pairings.map(p => [p.white_id, p.black_id].sort().join('|')));
const repeat = r2.some(p => seen.has([p.white_id, p.black_id].sort().join('|')));
check('round 2 does not repeat a round 1 pairing', !repeat);
} catch (e) {
failures.push('threw: ' + e.message);
console.log('\nERROR:', e.message);
} finally {
await teardown();
}
console.log('');
if (failures.length) {
console.log(`${failures.length} FAILURE(S):`);
failures.forEach(f => console.log(' - ' + f));
process.exit(1);
}
console.log('PRODUCTION REHEARSAL PASSED');
......@@ -27,7 +27,7 @@ CREATE TABLE el3ab_tournaments (
rounds_total int, swiss_rounds int,
min_players int DEFAULT 4, max_players int DEFAULT 64,
bye_value numeric DEFAULT 1.0,
registration_closes_at timestamptz, starts_at timestamptz,
registration_closes_at timestamptz, starts_at timestamptz NOT NULL,
status text DEFAULT 'draft', current_round int DEFAULT 0,
is_rated bool DEFAULT true, auto_start bool DEFAULT true,
created_by uuid, tiebreak_rules jsonb DEFAULT '[]'::jsonb,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment