Commit 9ce197dd authored by Mahmoud Aglan's avatar Mahmoud Aglan

test: mark every test account, and prove the pools never mix

Every account helper in tests/play sets profiles.is_test right after the
account is made (guest or GoTrue admin), before it can queue, and fails
loudly when the marker did not stick.

tests/play/match-pool.mjs: two test accounts and two unmarked stand-ins for
real players, made with the admin API (no guest signups). In every public
queue, in a bucket the app never offers a real player where the game has
one, a test account is not seated with a real player waiting longer, by the
queue call or the status poll; two test accounts are seated together on both
paths; a real player searching passes a waiting test account; two real
players still pair; domino's public tables keep apart the same way; and a
player's own token cannot set is_test either way. Against the code before
the fix it fails 42 checks; after, 63 of 63 pass. Teardown is verified.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent f5207cc4
......@@ -87,8 +87,9 @@ async function createAccount(name) {
if (!r.access_token || !r.profile?.id) throw new Error('account creation failed: ' + JSON.stringify(r).slice(0, 160));
const acc = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
accounts.push(acc);
await sb(`profiles?id=eq.${acc.id}`, { method: 'PATCH', body: { display_name: name, level: 4, coins: 500 } });
await sb(`profiles?id=eq.${acc.id}`, { method: 'PATCH', body: { is_test: true, display_name: name, level: 4, coins: 500 } });
acc.profile = (await sb(`profiles?id=eq.${acc.id}&select=*`))?.[0] || r.profile;
if (acc.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + acc.id);
return acc;
}
async function removeAccounts() {
......
......@@ -95,9 +95,10 @@ async function createAccount(i) {
const u = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
await sb(`profiles?id=eq.${u.id}`, {
method: 'PATCH', prefer: 'return=minimal',
body: { display_name: i === 0 ? 'سلمى التحدي' : 'كريم الخصم', elo_rapid: 1486 - i * 40, elo_blitz: 1412, elo_bullet: 1390 - i * 30, elo_domino: 1240 + i * 20, level: 7 + i * 5, xp: 3260, total_wins: 18 + i * 9 },
body: { is_test: true, display_name: i === 0 ? 'سلمى التحدي' : 'كريم الخصم', elo_rapid: 1486 - i * 40, elo_blitz: 1412, elo_bullet: 1390 - i * 30, elo_domino: 1240 + i * 20, level: 7 + i * 5, xp: 3260, total_wins: 18 + i * 9 },
});
u.profile = (await sb(`profiles?id=eq.${u.id}&select=*`))?.[0] || r.profile;
if (u.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + u.id);
u.display = u.profile.display_name;
users.push(u);
return u;
......
......@@ -111,9 +111,10 @@ async function createAccount(i) {
const names = ['سارة القطب', 'كريم ناصر', 'ليلى السيد'];
await sb(`profiles?id=eq.${acc.id}`, {
method: 'PATCH',
body: { display_name: `${names[i % names.length]}`, username: `${TAG.toLowerCase()}-${acc.id.slice(0, 8)}`, elo_rapid: 1450 + i * 40, elo_classical: 1500 + i * 40, elo_blitz: 1400, coins: 1200, level: 4 },
body: { is_test: true, display_name: `${names[i % names.length]}`, username: `${TAG.toLowerCase()}-${acc.id.slice(0, 8)}`, elo_rapid: 1450 + i * 40, elo_classical: 1500 + i * 40, elo_blitz: 1400, coins: 1200, level: 4 },
});
acc.profile = (await sb(`profiles?id=eq.${acc.id}&select=*`))?.[0] || r.profile;
if (acc.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + acc.id);
return acc;
}
......
......@@ -92,8 +92,9 @@ async function createAccount(n) {
if (!r.access_token || !r.profile?.id) throw new Error('guest account failed: ' + JSON.stringify(r).slice(0, 160));
const acc = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
created.users.push(acc);
await sb(`profiles?id=eq.${acc.id}`, { method: 'PATCH', body: { display_name: `${TAG} ${n}` } });
await sb(`profiles?id=eq.${acc.id}`, { method: 'PATCH', body: { is_test: true, display_name: `${TAG} ${n}` } });
acc.profile = (await sb(`profiles?id=eq.${acc.id}&select=*`))?.[0] || r.profile;
if (acc.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + acc.id);
return acc;
}
......
......@@ -103,9 +103,10 @@ async function createAccount(tag, extra = {}) {
const u = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token, tag };
await sb(`profiles?id=eq.${u.id}`, {
method: 'PATCH', prefer: 'return=minimal',
body: { display_name: `ZZZ-GHOST ${tag}`, elo_rapid: 1320, elo_blitz: 1290, elo_bullet: 1250, elo_classical: 1340, elo_domino: 1260, level: 11, xp: 5200, total_wins: 30, ...extra },
body: { is_test: true, display_name: `ZZZ-GHOST ${tag}`, elo_rapid: 1320, elo_blitz: 1290, elo_bullet: 1250, elo_classical: 1340, elo_domino: 1260, level: 11, xp: 5200, total_wins: 30, ...extra },
});
u.profile = (await sb(`profiles?id=eq.${u.id}&select=*`))?.[0] || r.profile;
if (u.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + u.id);
users.push(u);
return u;
}
......
......@@ -91,8 +91,9 @@ async function createAccount(name) {
if (!r.access_token || !r.profile?.id) throw new Error('account creation failed: ' + JSON.stringify(r).slice(0, 200));
const a = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
accounts.push(a);
await sb(`profiles?id=eq.${a.id}`, { method: 'PATCH', body: { display_name: name, level: 3 } });
await sb(`profiles?id=eq.${a.id}`, { method: 'PATCH', body: { is_test: true, display_name: name, level: 3 } });
a.profile = (await sb(`profiles?id=eq.${a.id}&select=*`))?.[0] || r.profile;
if (a.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + a.id);
return a;
}
......
/**
* Test accounts and real players never meet in a public queue.
*
* node tests/play/match-pool.mjs [--base http://127.0.0.1:8080] [--only chess,ludo,domino,backgammon,tables,guard]
*
* The play tests run against the production database, and a test account
* waiting in the public chess queue was once seated across a real player.
* Every account is in one pool, read from profiles.is_test
* (includes/match-pool.php), and every search keeps to the searcher's pool.
*
* Four throwaway accounts are made with the GoTrue admin API (no guest
* signups, so the shared signup limit is never touched), each signed in with
* a short-lived JWT minted from SUPABASE_JWT_SECRET in .env:
*
* T1, T2 test accounts (is_test set right after they are made)
* R, R2 stand-ins for real players: no marker at all
*
* For each game, in a bucket the app never offers a real player where the
* game has one (chess 'custom', backgammon sheshbesh to 9, ludo three seats),
* so a real player can never be seated with R while it waits:
*
* queue path R waits; T1 queues and is not seated with R (nor on its
* next status poll, which looks for earlier queuers); T2
* queues and is seated with T1, though R has waited longest.
* status path R, T1, T2 waiting in that order; T2's status poll seats T1,
* passing over the older R.
* real pair R waits, R2 queues: seated together, as before (not in
* domino, which has no bucket a real player cannot reach).
* other way T1 waits; R queues and polls and is not seated with T1.
* tables domino's public tables of three: T1 does not sit at R's
* table, R does not sit at T1's, T2 sits at T1's.
* guard a player's own token can set is_test neither on nor off.
*
* Everything made is deleted at the end, even when a check fails, and the
* deletion is verified.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const args = process.argv.slice(2);
const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const BASE = argOf('--base', 'http://127.0.0.1:8080');
const ONLY = argOf('--only', 'guard,chess,ludo,backgammon,domino,tables').split(',').filter(Boolean);
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const ROOT = new URL('../../', import.meta.url).pathname;
const SECRET = (() => {
if (process.env.SUPABASE_JWT_SECRET) return process.env.SUPABASE_JWT_SECRET;
const env = fs.readFileSync(path.join(ROOT, '.env'), 'utf8');
const m = env.match(/^SUPABASE_JWT_SECRET\s*=\s*"?([^"\n]+)"?/m);
if (!m) throw new Error('SUPABASE_JWT_SECRET missing from .env');
return m[1].trim();
})();
const STAMP = Date.now().toString(36).slice(-5).toLowerCase();
const PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const failures = [];
let passed = 0;
function check(label, cond, detail = '') {
if (cond) { passed++; console.log(` ok ${label}`); return true; }
failures.push(label + (detail ? ` (${detail})` : ''));
console.log(` FAIL ${label}${detail ? ' -- ' + detail : ''}`);
return false;
}
const show = (x) => JSON.stringify(x).slice(0, 220);
async function sb(p, { method = 'GET', body, prefer = 'return=representation', token } = {}) {
const r = await fetch(`${SB}/rest/v1/${p}`, {
method,
headers: { apikey: SK, Authorization: `Bearer ${token || SK}`, 'Content-Type': 'application/json', Prefer: prefer },
body: body ? JSON.stringify(body) : undefined,
});
const t = await r.text();
let data; try { data = JSON.parse(t); } catch { data = t; }
return token ? { status: r.status, data } : data;
}
const b64url = (s) => Buffer.from(s).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
function mintJwt(sub, email) {
const now = Math.floor(Date.now() / 1000);
const head = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = b64url(JSON.stringify({ sub, role: 'authenticated', aud: 'authenticated', email, is_anonymous: false, iat: now, exp: now + 3600 }));
const sig = crypto.createHmac('sha256', SECRET).update(`${head}.${body}`).digest('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
return `${head}.${body}.${sig}`;
}
const users = [];
async function createUser(label, isTest) {
const email = `zzpool_${STAMP}_${label.toLowerCase()}@example.com`;
const r = await fetch(`${SB}/auth/v1/admin/users`, {
method: 'POST',
headers: { apikey: SK, Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: crypto.randomBytes(12).toString('hex') + 'Aa1!', email_confirm: true }),
}).then(x => x.json());
if (!r.id) throw new Error('admin user failed: ' + show(r));
const u = { id: r.id, email, label, token: mintJwt(r.id, email) };
users.push(u);
for (let i = 0; i < 15; i++) {
const p = await sb(`profiles?id=eq.${u.id}&select=id`);
if (Array.isArray(p) && p.length) break;
await sleep(400);
}
const body = { display_name: `ZZZ-POOL ${label}`, username: `zzpool_${STAMP}_${label.toLowerCase()}`, level: 5, elo_rapid: 1200 };
if (isTest) body.is_test = true;
await sb(`profiles?id=eq.${u.id}`, { method: 'PATCH', prefer: 'return=minimal', body });
const p = (await sb(`profiles?id=eq.${u.id}&select=id,is_test`))?.[0];
if (!p || p.is_test !== isTest) throw new Error(`${label}: is_test should be ${isTest}, is ${p?.is_test}`);
return u;
}
async function api(u, ep, body) {
const r = await fetch(`${BASE}/api/${ep}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${u.token}` }, body: JSON.stringify(body),
});
const t = await r.text();
try { return JSON.parse(t); } catch { return { raw: t.slice(0, 200), status: r.status }; }
}
const matchIds = { chess: new Set(), ludo: new Set(), domino: new Set(), backgammon: new Set() };
// ── One queue, described ──────────────────────────────────────────────────
const GAMES = {
chess: {
ep: 'matchmaking.php', table: 'matchmaking_queue', who: 'player_id',
body: { game_key: 'chess', time_control: 'custom' },
row: (id) => ({ player_id: id, game_key: 'chess', time_control: 'custom', rating: 1200, status: 'waiting' }),
// A row nobody has claimed yet.
open: (r) => r && r.status === 'waiting' && !r.match_id && !r.matched_with,
select: 'id,status,match_id,matched_with',
},
ludo: {
ep: 'ludo-match.php', table: 'ludo_queue', who: 'user_id',
body: { player_count: 3 },
row: (id) => ({ user_id: id, player_count: 3 }),
open: (r) => r && !r.match_id,
select: 'id,match_id',
},
domino: {
ep: 'domino-match.php', table: 'domino_queue', who: 'user_id',
body: {},
row: (id) => ({ user_id: id }),
open: (r) => r && !r.match_id,
select: 'id,match_id',
},
backgammon: {
ep: 'backgammon-match.php', table: 'backgammon_queue', who: 'user_id',
body: { variant: 'sheshbesh', match_length: 9 },
row: (id) => ({ user_id: id, variant: 'sheshbesh', match_length: 9 }),
open: (r) => r && !r.match_id,
select: 'id,match_id',
},
};
async function rowOf(g, u) {
const G = GAMES[g];
const r = await sb(`${G.table}?${G.who}=eq.${u.id}&select=${G.select}&limit=1`);
return Array.isArray(r) ? r[0] || null : null;
}
async function insertRow(g, u) {
const G = GAMES[g];
const r = await sb(G.table, { method: 'POST', body: G.row(u.id) });
if (!Array.isArray(r) || !r[0]?.id) throw new Error(`${g}: could not insert ${u.label}'s row: ${show(r)}`);
await sleep(30); // queued_at / created_at strictly in insertion order
return r[0];
}
async function clearRows(g, list) {
const G = GAMES[g];
for (const u of list) await sb(`${G.table}?${G.who}=eq.${u.id}`, { method: 'DELETE', prefer: 'return=minimal' });
}
/** The match a hand-off names, recorded for teardown. */
function seen(g, res) {
if (res && res.match_id && res.match_id !== PLACEHOLDER) matchIds[g].add(res.match_id);
return res && res.match_id && res.match_id !== PLACEHOLDER ? res.match_id : null;
}
/** Is the other seat at this hand-off the expected account? */
async function seatedWith(g, res, me, other) {
const id = seen(g, res);
if (!id) return false;
if (g === 'chess') {
const m = (await sb(`matches?id=eq.${id}&select=white_player_id,black_player_id`))?.[0];
return !!m && [m.white_player_id, m.black_player_id].includes(me.id) && [m.white_player_id, m.black_player_id].includes(other.id);
}
const table = { ludo: 'ludo_matches', domino: 'domino_matches', backgammon: 'backgammon_matches' }[g];
const m = (await sb(`${table}?id=eq.${id}&select=players`))?.[0];
const players = Array.isArray(m?.players) ? m.players : (typeof m?.players === 'string' ? JSON.parse(m.players) : []);
return players.includes(me.id) && players.includes(other.id);
}
/** Drop a match the test made (and the queue rows that point at it), so the next phase starts clean. */
async function dropMatches(g) {
const table = { chess: 'matches', ludo: 'ludo_matches', domino: 'domino_matches', backgammon: 'backgammon_matches' }[g];
for (const id of matchIds[g]) {
await sb(`${GAMES[g].table}?match_id=eq.${id}`, { method: 'DELETE', prefer: 'return=minimal' });
for (const p of [`mp_log?match_id=eq.${id}`, `match_presence?match_id=eq.${id}`, `ghost_profiles?match_id=eq.${id}`]) await sb(p, { method: 'DELETE', prefer: 'return=minimal' });
await sb(`${table}?id=eq.${id}`, { method: 'DELETE', prefer: 'return=minimal' });
}
}
async function runGame(g, T1, T2, R, R2) {
const G = GAMES[g];
console.log(`\n=== ${g} ===`);
const all = [T1, T2, R];
await clearRows(g, all);
// Queue path. R has waited longest: oldest-first would seat R.
await insertRow(g, R);
try {
const q1 = await api(T1, G.ep, { action: 'queue', ...G.body });
check(`${g}: a test account queueing is not seated with the waiting real player`, q1.queued === true && !q1.match_id, show(q1));
check(`${g}: the real player's row is still unclaimed`, G.open(await rowOf(g, R)), show(await rowOf(g, R)));
const s1 = await api(T1, G.ep, { action: 'status', game_key: g });
check(`${g}: the test account's status poll does not pick up the earlier real queuer`, s1.waiting === true && !s1.match_id, show(s1));
check(`${g}: ...and the real player's row is still unclaimed`, G.open(await rowOf(g, R)));
const q2 = await api(T2, G.ep, { action: 'queue', ...G.body });
check(`${g}: two test accounts are seated together (queue path)`, await seatedWith(g, q2, T2, T1), show(q2));
check(`${g}: the real player waiting longest was passed over, not claimed`, G.open(await rowOf(g, R)), show(await rowOf(g, R)));
const s1b = await api(T1, G.ep, { action: 'status', game_key: g });
check(`${g}: the first test account is handed the same match`, seen(g, s1b) === q2.match_id, show(s1b));
} finally {
await dropMatches(g);
await clearRows(g, [T1, T2]);
}
// Status path: R, T1, T2 waiting in that order; T2's poll looks for earlier queuers.
try {
if (!G.open(await rowOf(g, R))) { await clearRows(g, [R]); await insertRow(g, R); }
await insertRow(g, T1);
await insertRow(g, T2);
const s2 = await api(T2, G.ep, { action: 'status', game_key: g });
check(`${g}: two test accounts are seated together (status path)`, await seatedWith(g, s2, T2, T1), show(s2));
check(`${g}: the older real row was passed over by the status poll`, G.open(await rowOf(g, R)), show(await rowOf(g, R)));
} finally {
await dropMatches(g);
await clearRows(g, all);
}
// Real players among themselves: unchanged. Only where the game has a
// bucket no real player can reach (two stand-ins waiting in the plain
// domino queue could catch a real player who queues at that moment).
if (R2 && g !== 'domino') {
try {
await insertRow(g, R);
const q = await api(R2, G.ep, { action: 'queue', ...G.body });
check(`${g}: two real players are still seated together`, await seatedWith(g, q, R2, R), show(q));
} finally {
await dropMatches(g);
await clearRows(g, [R, R2]);
}
}
// The other way: a test account waits, the real player searches.
try {
const q1 = await api(T1, G.ep, { action: 'queue', ...G.body });
check(`${g}: the test account waits`, q1.queued === true, show(q1));
const qr = await api(R, G.ep, { action: 'queue', ...G.body });
check(`${g}: a real player queueing is not seated with the waiting test account`, qr.queued === true && !qr.match_id, show(qr));
const sr = await api(R, G.ep, { action: 'status', game_key: g });
check(`${g}: the real player's status poll does not pick up the earlier test account`, sr.waiting === true && !sr.match_id, show(sr));
check(`${g}: the test account's row is still unclaimed`, G.open(await rowOf(g, T1)), show(await rowOf(g, T1)));
seen(g, qr); seen(g, sr);
} finally {
await api(R, G.ep, { action: 'dequeue', game_key: g }).catch(() => {});
await api(T1, G.ep, { action: 'dequeue', game_key: g }).catch(() => {});
await dropMatches(g);
await clearRows(g, all);
}
}
/** Domino's public tables of three (table_quick): the same rule, table by table. */
async function runTables(T1, T2, R) {
console.log('\n=== domino public tables ===');
const quick = (u) => api(u, 'domino-match.php', { action: 'table_quick', players: 3, target: 51 });
const leave = (u, id) => api(u, 'domino-match.php', { action: 'table_leave', match_id: id }).catch(() => {});
const made = [];
try {
const r1 = await quick(R);
if (r1.match_id) { made.push([R, r1.match_id]); matchIds.domino.add(r1.match_id); }
check('tables: the real player opens a public table', !!r1.match_id && r1.seat === 0, show(r1));
const t1 = await quick(T1);
if (t1.match_id) { made.push([T1, t1.match_id]); matchIds.domino.add(t1.match_id); }
check('tables: a test account does not sit at the real player\'s table', !!t1.match_id && t1.match_id !== r1.match_id && t1.seat === 0, show(t1));
// R leaves its own table and looks again: T1's table is open now.
await leave(R, r1.match_id);
const r2 = await quick(R);
if (r2.match_id) { made.push([R, r2.match_id]); matchIds.domino.add(r2.match_id); }
check('tables: the real player does not sit at the test account\'s table', !!r2.match_id && r2.match_id !== t1.match_id, show(r2));
const t2 = await quick(T2);
if (t2.match_id) { made.push([T2, t2.match_id]); matchIds.domino.add(t2.match_id); }
check('tables: a second test account sits at the first one\'s table', t2.match_id === t1.match_id && t2.seat === 1, show(t2));
} finally {
for (const [u, id] of made) await leave(u, id);
}
}
/** A player's own token can change is_test neither way. */
async function runGuard(T1, R) {
console.log('\n=== guard ===');
const on = await sb(`profiles?id=eq.${R.id}`, { method: 'PATCH', token: R.token, body: { is_test: true } });
const rNow = (await sb(`profiles?id=eq.${R.id}&select=is_test`))?.[0]?.is_test;
check('a player cannot put themselves in the test pool', rNow === false && on.status >= 400, `status ${on.status}, is_test ${rNow}`);
const off = await sb(`profiles?id=eq.${T1.id}`, { method: 'PATCH', token: T1.token, body: { is_test: false } });
const tNow = (await sb(`profiles?id=eq.${T1.id}&select=is_test`))?.[0]?.is_test;
check('a test account cannot take itself out of the test pool', tNow === true && off.status >= 400, `status ${off.status}, is_test ${tNow}`);
const name = await sb(`profiles?id=eq.${R.id}`, { method: 'PATCH', token: R.token, body: { display_name: `ZZZ-POOL R ${STAMP}` } });
check('...while the player still edits their own name', name.status < 300, `status ${name.status}`);
}
async function cleanup() {
console.log('\n=== teardown ===');
const ids = users.map(u => u.id);
if (!ids.length) return;
const list = ids.join(',');
const add = (g, rows) => { if (Array.isArray(rows)) rows.forEach(r => matchIds[g].add(r.id)); };
add('chess', await sb(`matches?or=(white_player_id.in.(${list}),black_player_id.in.(${list}))&select=id`));
for (const id of ids) {
add('ludo', await sb(`ludo_matches?or=(host_id.eq.${id},players.cs.["${id}"])&select=id`));
add('domino', await sb(`domino_matches?or=(host_id.eq.${id},created_by.eq.${id},players.cs.["${id}"])&select=id`));
add('backgammon', await sb(`backgammon_matches?or=(host_id.eq.${id},created_by.eq.${id},players.cs.["${id}"])&select=id`));
}
for (const g of Object.keys(GAMES)) { await clearRows(g, users); await dropMatches(g); }
await sb(`ghost_profiles?for_player=in.(${list})`, { method: 'DELETE', prefer: 'return=minimal' });
for (const id of ids) {
for (const p of [`mp_log?player_id=eq.${id}`, `match_presence?player_id=eq.${id}`, `notifications?user_id=eq.${id}`, `economy_transactions?player_id=eq.${id}`])
await sb(p, { method: 'DELETE', prefer: 'return=minimal' });
}
for (const id of ids) {
for (let i = 0; i < 3; i++) {
await sb(`profiles?id=eq.${id}`, { method: 'DELETE', prefer: 'return=minimal' });
const left = await sb(`profiles?id=eq.${id}&select=id`);
if (Array.isArray(left) && !left.length) break;
await sleep(500);
}
await fetch(`${SB}/auth/v1/admin/users/${id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
// Verify.
const left = [];
for (const id of ids) {
for (const p of [`profiles?id=eq.${id}`, `ghost_profiles?for_player=eq.${id}`, `matchmaking_queue?player_id=eq.${id}`, `ludo_queue?user_id=eq.${id}`,
`domino_queue?user_id=eq.${id}`, `backgammon_queue?user_id=eq.${id}`, `mp_log?player_id=eq.${id}`, `match_presence?player_id=eq.${id}`,
`matches?white_player_id=eq.${id}`, `matches?black_player_id=eq.${id}`, `ludo_matches?players=cs.["${id}"]`, `domino_matches?players=cs.["${id}"]`,
`backgammon_matches?players=cs.["${id}"]`]) {
const r = await sb(p + '&select=*&limit=1');
if (!Array.isArray(r) || r.length) left.push(p);
}
const a = await fetch(`${SB}/auth/v1/admin/users/${id}`, { headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => null);
if (a && a.status === 200) left.push(`auth user ${id}`);
}
const n = Object.values(matchIds).reduce((s, x) => s + x.size, 0);
console.log(` removed ${n} match(es) and ${ids.length} account(s)`);
check('teardown leaves nothing behind', left.length === 0, left.join(', '));
}
let stopping = false;
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, async () => {
if (stopping) return;
stopping = true;
console.log(`\n${sig}: cleaning up`);
await cleanup().catch(() => {});
process.exit(130);
});
}
try {
const T1 = await createUser('T1', true);
const T2 = await createUser('T2', true);
const R = await createUser('R', false);
const R2 = await createUser('R2', false);
console.log(`accounts: T1 ${T1.id}, T2 ${T2.id} (test), R ${R.id}, R2 ${R2.id} (real stand-ins)`);
if (ONLY.includes('guard')) await runGuard(T1, R);
for (const g of ['chess', 'ludo', 'backgammon', 'domino']) if (ONLY.includes(g)) await runGame(g, T1, T2, R, R2);
if (ONLY.includes('tables')) await runTables(T1, T2, R);
} catch (e) {
failures.push('crashed: ' + (e?.stack || e).toString().slice(0, 500));
console.log('CRASH', e);
} finally {
await cleanup().catch(e => failures.push('cleanup crashed: ' + e));
console.log(failures.length ? `\n${passed} passed, ${failures.length} FAILED:\n ${failures.join('\n ')}` : `\nall ${passed} checks passed`);
process.exit(failures.length ? 1 : 0);
}
......@@ -141,9 +141,10 @@ async function createUser(label, name, extra = {}) {
}
await sb(`profiles?id=eq.${u.id}`, {
method: 'PATCH', prefer: 'return=minimal',
body: { display_name: name, username: `zzorg_${STAMP.toLowerCase()}_${label.toLowerCase()}`, level: 6, xp: 2400, ...extra },
body: { is_test: true, display_name: name, username: `zzorg_${STAMP.toLowerCase()}_${label.toLowerCase()}`, level: 6, xp: 2400, ...extra },
});
u.profile = (await sb(`profiles?id=eq.${u.id}&select=*`))?.[0];
if (u.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + u.id);
return u;
}
......
......@@ -81,6 +81,13 @@ function mint(sub, email, anonymous = false) {
return `${head}.${body}.${sig}`;
}
/** Mark an account as made by the tests (profiles.is_test): public queues never seat it with a real player. */
async function markTest(id) {
await sb(`profiles?id=eq.${id}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true } });
const p = await sb(`profiles?id=eq.${id}&select=is_test`);
if (p?.[0]?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + id);
}
async function makeUser(label, i) {
const email = `zzredeem-${STAMP}-${label.toLowerCase()}@el3ab.test`;
const password = `Zz-${STAMP}-${label}-pw`;
......@@ -91,7 +98,8 @@ async function makeUser(label, i) {
if (!r.id) throw new Error('admin create user failed: ' + show(r));
const u = { label, id: r.id, email, password, token: mint(r.id, email), ip: `10.88.${i}.${(parseInt(STAMP, 36) % 200) + 1}` };
made.users.push(u);
await sb(`profiles?id=eq.${u.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { display_name: `ZZ ${label} ${STAMP}`, level: 3 } });
await sb(`profiles?id=eq.${u.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true, display_name: `ZZ ${label} ${STAMP}`, level: 3 } });
await markTest(u.id);
return u;
}
......@@ -528,7 +536,8 @@ async function guestFlow(users, org, basic, approvalCode, group) {
const G = { label: 'G', id: g.user.id, token: g.access_token, ip: '10.89.3.2' };
made.users.push(G);
made.ips.add('10.89.3.1');
await sb(`profiles?id=eq.${G.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { coins: 123 } });
await sb(`profiles?id=eq.${G.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true, coins: 123 } });
await markTest(G.id);
const gp = await api(G, 'redeem.php', { query: { action: 'preview', code: basic.code, source: 'link' } });
check('guest preview: account guest, intent_saved', gp.status === 200 && gp.data.account === 'guest' && gp.data.intent_saved === true, show(gp.data));
const ga = await api(G, 'redeem.php', { body: { action: 'apply', code: basic.code, source: 'link' } });
......@@ -577,13 +586,13 @@ async function signupFlow(users, org, basic) {
section('signup with a code');
const good = await fetch(`${BASE}/api/auth.php`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Real-IP': '10.89.4.1' },
body: JSON.stringify({ action: 'signup', email: `zzredeem-${STAMP}-su1@el3ab.test`, password: 'Zz-signup-1', username: `zzs1_${STAMP}`, redeem_code: basic.display, redeem_source: 'link' }) }).then(x => x.json());
if (good.user?.id) made.users.push({ label: 'SU1', id: good.user.id });
if (good.user?.id) { made.users.push({ label: 'SU1', id: good.user.id }); await markTest(good.user.id); }
check('signup with a good code: tokens and redeem applied', !!good.access_token && good.redeem?.status === 'applied', show(good.redeem || good));
const r1 = good.user?.id ? await sb(`redeem_redemptions?player_id=eq.${good.user.id}&select=source,was_new_signup`) : [];
check('signup redemption keeps the link source and is a new signup', r1?.[0]?.source === 'link' && r1[0].was_new_signup === true, show(r1));
const bad = await fetch(`${BASE}/api/auth.php`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Real-IP': '10.89.4.1' },
body: JSON.stringify({ action: 'signup', email: `zzredeem-${STAMP}-su2@el3ab.test`, password: 'Zz-signup-2', username: `zzs2_${STAMP}`, redeem_code: 'ZZZZ-ZZZZ' }) }).then(x => x.json());
if (bad.user?.id) made.users.push({ label: 'SU2', id: bad.user.id, email: `zzredeem-${STAMP}-su2@el3ab.test`, password: 'Zz-signup-2', token: bad.access_token });
if (bad.user?.id) { made.users.push({ label: 'SU2', id: bad.user.id, email: `zzredeem-${STAMP}-su2@el3ab.test`, password: 'Zz-signup-2', token: bad.access_token }); await markTest(bad.user.id); }
check('signup with a bad code: the account is made anyway, redeem error block', !!bad.access_token && bad.redeem?.ok === false && bad.redeem.error === 'not_found' && bad.redeem.keep_code === false, show(bad.redeem || bad));
const dup = await fetch(`${BASE}/api/auth.php`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Real-IP': '10.89.4.1' },
body: JSON.stringify({ action: 'signup', email: users.O.email, password: 'Zz-signup-3', username: `zzs3_${STAMP}` }) });
......
......@@ -96,8 +96,9 @@ async function adminUser(label) {
if (!login.access_token) throw new Error('login failed: ' + JSON.stringify(login).slice(0, 200));
p.token = login.access_token;
p.refresh = login.refresh_token;
await sb(`profiles?id=eq.${p.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { display_name: `لاعب ${label} ${STAMP}`, username: `zzrd_${label}_${STAMP}` } });
await sb(`profiles?id=eq.${p.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true, display_name: `لاعب ${label} ${STAMP}`, username: `zzrd_${label}_${STAMP}` } });
p.profile = (await sb(`profiles?id=eq.${p.id}&select=*`))?.[0] || null;
if (p.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + p.id);
return p;
}
......@@ -251,7 +252,11 @@ async function linkSignup() {
await tap(page, '#reg-btn');
const landed = await seen(page, '.rd-veil .rd-stamp', 25000);
const st = await stored(page);
if (st.auth?.userId) people.push({ id: st.auth.userId, email, label: 'new' });
if (st.auth?.userId) {
people.push({ id: st.auth.userId, email, label: 'new' });
// A test account (profiles.is_test): public queues never seat it with a real player.
await sb(`profiles?id=eq.${st.auth.userId}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true } });
}
check('the reveal stamps the ticket after signup', landed);
check('the welcome card names the org', await waitFor(page, (n) => (document.querySelector('.rd-welcome-title')?.textContent || '').includes(n), ORG_NAME_AR, 8000));
await shot(page, 'signup-reveal');
......@@ -366,6 +371,7 @@ async function guestUpgrade() {
if (!g.access_token || !g.profile?.id) { check('a guest account', false, JSON.stringify(g).slice(0, 160)); return; }
const guest = { id: g.profile.id, token: g.access_token, refresh: g.refresh_token, profile: g.profile, label: 'guest' };
people.push(guest);
await sb(`profiles?id=eq.${guest.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true } });
const tab = await newPage('guest');
const { page } = tab;
await openAs(tab, guest, `r/${code.display}`, { guest: true });
......
......@@ -49,8 +49,9 @@ export async function createAccount(name) {
if (!r.access_token || !r.profile?.id) throw new Error('account creation failed: ' + JSON.stringify(r).slice(0, 160));
const a = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token, name };
accounts.push(a);
await sb(`profiles?id=eq.${a.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { display_name: name, level: 5 } });
await sb(`profiles?id=eq.${a.id}`, { method: 'PATCH', prefer: 'return=minimal', body: { is_test: true, display_name: name, level: 5 } });
a.profile = (await sb(`profiles?id=eq.${a.id}&select=*`))?.[0] || r.profile;
if (a.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + a.id);
return a;
}
......
......@@ -88,9 +88,10 @@ async function createAccount(i) {
const u = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token, name: `${TAG} ${i === 0 ? 'A' : 'B'}` };
await sb(`profiles?id=eq.${u.id}`, {
method: 'PATCH', prefer: 'return=minimal',
body: { display_name: i === 0 ? 'ليلى السيد' : 'يوسف كمال', elo_rapid: 1486 - i * 40, elo_blitz: 1412, elo_bullet: 1390 - i * 30, elo_domino: 1240, level: 7 + i * 5, xp: 3260, total_wins: 18 + i * 9 },
body: { is_test: true, display_name: i === 0 ? 'ليلى السيد' : 'يوسف كمال', elo_rapid: 1486 - i * 40, elo_blitz: 1412, elo_bullet: 1390 - i * 30, elo_domino: 1240, level: 7 + i * 5, xp: 3260, total_wins: 18 + i * 9 },
});
u.profile = (await sb(`profiles?id=eq.${u.id}&select=*`))?.[0] || r.profile;
if (u.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + u.id);
u.display = u.profile.display_name;
users.push(u);
return u;
......
......@@ -84,9 +84,10 @@ async function createAccount(i) {
users.push(u);
await sb(`profiles?id=eq.${u.id}`, {
method: 'PATCH', prefer: 'return=minimal',
body: { display_name: u.name, username: `zzsoc_${STAMP}_${u.label.toLowerCase()}`, level: 4 + i * 3, xp: 1800 },
body: { is_test: true, display_name: u.name, username: `zzsoc_${STAMP}_${u.label.toLowerCase()}`, level: 4 + i * 3, xp: 1800 },
});
u.profile = (await sb(`profiles?id=eq.${u.id}&select=*`))?.[0] || r.profile;
if (u.profile?.is_test !== true) throw new Error('the test marker (profiles.is_test) did not stick on ' + u.id);
u.username = u.profile.username;
return u;
}
......
......@@ -101,7 +101,7 @@ async function createGuest() {
made.user = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
await sb(`profiles?id=eq.${made.user.id}`, {
method: 'PATCH', prefer: 'return=minimal',
body: { display_name: TAG, username: `zzrules_${STAMP}`, coins: 50, elo_rapid: 1200, level: 6 },
body: { is_test: true, display_name: TAG, username: `zzrules_${STAMP}`, coins: 50, elo_rapid: 1200, level: 6 },
});
made.user.profile = await one(`profiles?id=eq.${made.user.id}&select=*`);
}
......
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