Commit 41180847 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test: a production replica, a PostgREST-shaped shim, and the hosting migrations in the suite

tests/schema.sql is now generated from read-only dumps of production: the 35
tables the tournament system touches, with their checks, keys, indexes, RLS,
policies and grants, and the production functions the hosting packages build
on (tournament_register, _withdraw, _refund_entry, _refund_due, _settle,
_redeem_tournament, redeem_fold, complete_match, award_coins). It used to be a
hand-written subset without any of those functions.

tests/run.sh loads it strictly, applies every 2026-10-27-hosting-*.sql (the
schema file first), proves a second run leaves the schema identical and that
the hosting tables stay locked to the service role, then runs every
tests/test_hosting_*.php after the existing suites.

PgShim now behaves like PostgREST where the engine relies on it: rpc() runs the
real SQL function with named arguments and the client's return shapes, upsert()
exists, jsonb, arrays and numerics come back typed, and a PHP list written to
an array column becomes a Postgres array. The test router's supabaseRpc() goes
through it.

The suite was red before this (3 passed, 2 failed): registration and
settlement are SQL functions now, and the shim answered every rpc with []. The
end-to-end test also crashed at section 9, so sections 9 to 21 never ran; its
heartbeat checks still looked in game_state, while heartbeats moved to
match_presence. Now: 8 passed, 0 failed.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 873278e4
This diff is collapsed.
...@@ -35,7 +35,10 @@ function supabaseRpc(string $fn, array $params = []): ?array { ...@@ -35,7 +35,10 @@ function supabaseRpc(string $fn, array $params = []): ?array {
$__pg->update($params['p_table'], ['game_state' => $gs], ['id' => 'eq.' . $params['p_match_id']]); $__pg->update($params['p_table'], ['game_state' => $gs], ['id' => 'eq.' . $params['p_match_id']]);
return ['ok' => true]; return ['ok' => true];
} }
return []; // Anything else runs the real function when schema.sql carries it
// (complete_match, the tournament functions); otherwise [] as before.
global $__pg;
return $__pg->rpc($fn, $params);
} }
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
......
...@@ -5,7 +5,11 @@ ...@@ -5,7 +5,11 @@
# ./tests/run.sh # ./tests/run.sh
# #
# Everything runs against a throwaway local Postgres carrying a replica of the # Everything runs against a throwaway local Postgres carrying a replica of the
# production types and tables. No production data is read or written. # production types and tables (schema.sql), with every
# migrations/2026-10-27-hosting-*.sql applied on top, the schema file first.
# No production data is read or written.
#
# Engine tests for the hosting packages are picked up as test_hosting_*.php.
# #
# Requires: php (8.1+), node (18+), postgres client tools (initdb, pg_ctl, psql). # Requires: php (8.1+), node (18+), postgres client tools (initdb, pg_ctl, psql).
...@@ -63,9 +67,89 @@ for _ in $(seq 1 20); do ...@@ -63,9 +67,89 @@ for _ in $(seq 1 20); do
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -tAc 'select 1' >/dev/null 2>&1 && break psql -h 127.0.0.1 -p "$PGPORT" -U postgres -tAc 'select 1' >/dev/null 2>&1 && break
sleep 0.5 sleep 0.5
done done
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -q -f schema.sql >/dev/null 2>&1 PSQL=(psql -h 127.0.0.1 -p "$PGPORT" -U postgres -v ON_ERROR_STOP=1 -q)
if ! "${PSQL[@]}" -f schema.sql > /tmp/el3ab_schema_out 2>&1; then
echo " the replica schema failed to load:"
sed 's/^/ /' /tmp/el3ab_schema_out | tail -20
exit 1
fi
echo " schema loaded" echo " schema loaded"
# The big-tournament hosting migrations, in the order production gets them:
# the shared schema first, then each package's functions file.
HOSTING=()
[ -f ../migrations/2026-10-27-hosting-schema.sql ] && HOSTING+=(../migrations/2026-10-27-hosting-schema.sql)
for f in ../migrations/2026-10-27-hosting-*.sql; do
[ -f "$f" ] && [ "$f" != ../migrations/2026-10-27-hosting-schema.sql ] && HOSTING+=("$f")
done
apply_hosting() {
local f
for f in "${HOSTING[@]}"; do
echo "-- $f"
"${PSQL[@]}" -f "$f" || return 1
done
}
schema_dump() {
pg_dump -h 127.0.0.1 -p "$PGPORT" -U postgres --schema-only -n public -n storage | grep -v -E '^\\(un)?restrict '
}
# A second run of every hosting migration must leave the schema byte-identical.
hosting_idempotent() {
local a b rc
a=$(mktemp -t el3ab_schema_a) b=$(mktemp -t el3ab_schema_b)
schema_dump > "$a"
apply_hosting > /dev/null 2>&1 || { apply_hosting; rm -f "$a" "$b"; return 1; }
schema_dump > "$b"
diff "$a" "$b"; rc=$?
rm -f "$a" "$b"
return $rc
}
# The tables the hosting schema locks: RLS on, the service role only, and the
# two read policies Realtime needs left in place.
hosting_locked() {
"${PSQL[@]}" -tA <<'SQL'
DO $$
DECLARE
locked text[] := ARRAY['tournament_private_settings','tournament_teams','tournament_team_matches',
'tournament_stage_entries','tournament_form_fields','tournament_registration_answers',
'tournament_prize_categories','tournament_disputes','tournament_certificates','tournament_notices_sent',
'tournament_forbidden_pairs','tournament_phases','tournament_brackets','bracket_matches',
'tournament_registrations','tournament_media','tournament_ad_slots','tournament_announcements','sponsors',
'tournament_sponsorships','tournament_prize_payouts'];
bad text; n int;
BEGIN
SELECT count(*) INTO n FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relname = ANY (locked);
IF n <> 21 THEN RAISE EXCEPTION 'expected 21 locked tables, found %', n; END IF;
SELECT string_agg(relname, ', ') INTO bad FROM pg_class
WHERE relnamespace = 'public'::regnamespace AND relname = ANY (locked) AND NOT relrowsecurity;
IF bad IS NOT NULL THEN RAISE EXCEPTION 'RLS is off on: %', bad; END IF;
SELECT string_agg(c.relname || ' ' || CASE a.grantee WHEN 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END
|| ' ' || a.privilege_type, ', ') INTO bad
FROM pg_class c CROSS JOIN LATERAL aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
WHERE c.relnamespace = 'public'::regnamespace AND c.relname = ANY (locked)
AND a.grantee IN (0, 'anon'::regrole, 'authenticated'::regrole);
IF bad IS NOT NULL THEN RAISE EXCEPTION 'still granted: %', bad; END IF;
SELECT string_agg(t, ', ') INTO bad FROM unnest(locked) t
WHERE NOT (has_table_privilege('service_role', 'public.' || t, 'SELECT')
AND has_table_privilege('service_role', 'public.' || t, 'INSERT')
AND has_table_privilege('service_role', 'public.' || t, 'UPDATE')
AND has_table_privilege('service_role', 'public.' || t, 'DELETE'));
IF bad IS NOT NULL THEN RAISE EXCEPTION 'service_role cannot write: %', bad; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.el3ab_tournaments'::regclass
AND polname = 'Tournaments viewable by everyone' AND polcmd = 'r')
OR NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.el3ab_tournament_rounds'::regclass
AND polname = 'tournament_rounds_viewable_by_everyone' AND polcmd = 'r') THEN
RAISE EXCEPTION 'a Realtime read policy is missing';
END IF;
END $$;
SQL
}
step "Hosting migrations (${#HOSTING[@]} file(s))"
run "Every 2026-10-27-hosting-*.sql applies, the schema file first" apply_hosting
run "A second run of them leaves the schema identical" hosting_idempotent
run "Hosting tables: RLS on, service role only, read policies kept" hosting_locked
step "Starting the API under test" step "Starting the API under test"
php -S 127.0.0.1:$AUTHPORT authstub.php > .auth.log 2>&1 & php -S 127.0.0.1:$AUTHPORT authstub.php > .auth.log 2>&1 &
AUTH_PID=$! AUTH_PID=$!
...@@ -86,5 +170,12 @@ run "Tournament engine against real Postgres" php test_integra ...@@ -86,5 +170,12 @@ run "Tournament engine against real Postgres" php test_integra
run "Local JWT verification (signature, expiry, alg=none)" php test_auth.php run "Local JWT verification (signature, expiry, alg=none)" php test_auth.php
run "Full API end-to-end: matchmaking, a game, a tournament" node test_e2e.mjs run "Full API end-to-end: matchmaking, a game, a tournament" node test_e2e.mjs
# Each hosting package adds its engine tests as tests/test_hosting_<name>.php.
for t in test_hosting_*.php; do
[ -f "$t" ] || continue
name=${t#test_hosting_}
run "Hosting: ${name%.php}" php "$t"
done
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1 [ "$FAIL" -eq 0 ] || exit 1
This diff is collapsed.
...@@ -352,9 +352,11 @@ await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control ...@@ -352,9 +352,11 @@ await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control
const hm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[3])).data; const hm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[3])).data;
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[2]); await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[2]);
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]); await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`)); // One match_presence row per player (api/game.php handleChessHeartbeat); the
check('both players\' heartbeats are recorded', !!hs[`hb_${P[2]}`] && !!hs[`hb_${P[3]}`], JSON.stringify(hs)); // heartbeat no longer writes into game_state, where it raced the moves.
check('one heartbeat does not overwrite the other', Object.keys(hs).filter(k => k.startsWith('hb_')).length === 2, JSON.stringify(Object.keys(hs))); const beats = sql(`SELECT player_id FROM match_presence WHERE match_id='${hm.match_id}' ORDER BY player_id`).split('\n').filter(Boolean);
check('both players\' heartbeats are recorded', beats.includes(P[2]) && beats.includes(P[3]), JSON.stringify(beats));
check('one heartbeat does not overwrite the other', beats.length === 2, JSON.stringify(beats));
// A draw offer must survive a heartbeat landing on the same row. // A draw offer must survive a heartbeat landing on the same row.
sql(`UPDATE matches SET game_state = game_state || '{"draw_offer":"${P[2]}"}'::jsonb WHERE id='${hm.match_id}'`); sql(`UPDATE matches SET game_state = game_state || '{"draw_offer":"${P[2]}"}'::jsonb WHERE id='${hm.match_id}'`);
......
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