diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..6b4898d9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: tests + +on: + pull_request: + push: + branches: + - "main" + workflow_dispatch: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + runs-on: ubuntu-24.04 + steps: + - name: Check out the repo + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Typecheck test suites + run: yarn typecheck:tests + - name: Run unit tests + run: yarn test:unit + + sql: + # Boots a throwaway TimescaleDB per suite via testcontainers and drives the + # real HasuraService.setup() migration pipeline, then exercises the + # triggers/functions. Docker is available on GitHub-hosted runners. + runs-on: ubuntu-24.04 + steps: + - name: Check out the repo + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Pre-pull the database image + run: docker pull timescale/timescaledb:latest-pg17 + - name: Run SQL tests + run: yarn test:sql diff --git a/hasura/fixtures/fixtures.sql b/hasura/fixtures/fixtures.sql index 9a1a48bc..0eabc805 100644 --- a/hasura/fixtures/fixtures.sql +++ b/hasura/fixtures/fixtures.sql @@ -1951,7 +1951,9 @@ BEGIN ('Asia', 'Asia', false), ('Australia', 'Australia', false), ('Middle East', 'Middle East', false), - ('Africa', 'Africa', false) + ('Africa', 'Africa', false), + -- Same spelling as seeds/game-server-node-regions.sql; triggers match on 'Lan'. + ('Lan', 'Lan', true) ON CONFLICT (value) DO NOTHING; INSERT INTO game_server_nodes (id, public_ip, lan_ip, start_port_range, end_port_range, region, status, enabled, label) @@ -1965,7 +1967,7 @@ BEGIN ('fixture-node-australia', '198.51.100.7'::inet, NULL, 27015, 27025, 'Australia', 'Online', true, 'Fixture Australia'), ('fixture-node-middle-east', '198.51.100.8'::inet, NULL, 27015, 27025, 'Middle East', 'Online', true, 'Fixture Middle East'), ('fixture-node-africa', '198.51.100.9'::inet, NULL, 27015, 27025, 'Africa', 'Online', true, 'Fixture Africa'), - ('fixture-node-lan', '198.51.100.10'::inet, '192.168.1.100'::inet, 27015, 27025, 'LAN', 'Online', true, 'Fixture LAN') + ('fixture-node-lan', '198.51.100.10'::inet, '192.168.1.100'::inet, 27015, 27025, 'Lan', 'Online', true, 'Fixture LAN') ON CONFLICT (id) DO UPDATE SET public_ip = EXCLUDED.public_ip, lan_ip = EXCLUDED.lan_ip, diff --git a/hasura/functions/match/map-veto/verify_map_veto_pick.sql b/hasura/functions/match/map-veto/verify_map_veto_pick.sql index 03a88ffd..dcbae0a2 100644 --- a/hasura/functions/match/map-veto/verify_map_veto_pick.sql +++ b/hasura/functions/match/map-veto/verify_map_veto_pick.sql @@ -16,6 +16,13 @@ BEGIN -- Get map pool for the match pickType := get_map_veto_type(_match); + -- No active step (match not in Veto, or map veto disabled): reject rather + -- than let the NULL propagate through the comparisons below, where every + -- guard would silently pass. + IF pickType IS NULL THEN + RAISE EXCEPTION 'No map veto in progress' USING ERRCODE = '22000'; + END IF; + -- Check if the pickType matches the type of the match_map_veto_pick veto IF match_map_veto_pick.type != pickType THEN RAISE EXCEPTION 'Expected pick type of %', pickType USING ERRCODE = '22000'; diff --git a/hasura/functions/tournaments/assign_team_to_bracket_slot.sql b/hasura/functions/tournaments/assign_team_to_bracket_slot.sql index 134f6ae1..aa47a995 100644 --- a/hasura/functions/tournaments/assign_team_to_bracket_slot.sql +++ b/hasura/functions/tournaments/assign_team_to_bracket_slot.sql @@ -23,7 +23,13 @@ BEGIN -- Determine the correct slot from feeder ordering: -- 1. Loser drops (loser_parent_bracket_id) come before winner feeds (parent_bracket_id) - -- 2. Within same type, order by round then match_number + -- 2. Winner-bracket feeders before loser-bracket feeders: the grand final + -- is fed by the WB final and the LB final, which tie on round AND + -- match_number — without this key the ordering is non-deterministic, + -- both feeders resolve to the same slot, and the second finisher + -- overwrites the first (leaving the GF one team short, which then + -- resolves as a bye and crowns the wrong champion). + -- 3. Within same type, order by round then match_number -- This matches the bracket generation layout. IF _source_bracket_id IS NOT NULL THEN SELECT pos INTO slot_position @@ -32,6 +38,7 @@ BEGIN row_number() OVER ( ORDER BY CASE WHEN f.loser_parent_bracket_id = _target_bracket_id THEN 0 ELSE 1 END, + CASE WHEN f.path = 'LB' THEN 1 ELSE 0 END, f.round, f.match_number ) AS pos diff --git a/hasura/functions/tournaments/calculate_tournament_trophies.sql b/hasura/functions/tournaments/calculate_tournament_trophies.sql index 24e20048..10fc9c8d 100644 --- a/hasura/functions/tournaments/calculate_tournament_trophies.sql +++ b/hasura/functions/tournaments/calculate_tournament_trophies.sql @@ -37,35 +37,73 @@ BEGIN RETURN; END IF; - -- Placement comes from v_team_stage_results, which is the single source of - -- truth for tournament ordering (DE uses elimination round, RR/Swiss/SE + -- When the last stage played a third-place decider, the brackets order the + -- medals directly: final winner/loser take gold/silver, the decider's + -- winner takes bronze. The standings view cannot do this — the runner-up + -- and the third-place winner both finish 1-1, so its wins-based + -- tiebreakers (round ratio, team KDR, finally team id) pick silver and + -- bronze arbitrarily. + SELECT + CASE WHEN fm.winning_lineup_id = fm.lineup_1_id + THEN fb.tournament_team_id_1 ELSE fb.tournament_team_id_2 END, + CASE WHEN fm.winning_lineup_id = fm.lineup_1_id + THEN fb.tournament_team_id_2 ELSE fb.tournament_team_id_1 END, + CASE WHEN tm.winning_lineup_id = tm.lineup_1_id + THEN tb3.tournament_team_id_1 ELSE tb3.tournament_team_id_2 END + INTO _winning_team_id, _runner_up_team_id, _third_team_id + FROM public.tournament_stages ts + JOIN public.tournament_brackets fb + ON fb.tournament_stage_id = ts.id + JOIN public.matches fm ON fm.id = fb.match_id + JOIN public.tournament_brackets tb3 + ON tb3.tournament_stage_id = ts.id + AND tb3.round = fb.round + AND tb3.match_number = 2 + AND tb3.path = 'WB' + JOIN public.matches tm ON tm.id = tb3.match_id + WHERE ts.id = _final_stage_id + AND ts.type = 'SingleElimination' + AND ts.third_place_match = true + AND fb.path = 'WB' + AND fb.match_number = 1 + AND fb.round = ( + SELECT MAX(round) FROM public.tournament_brackets + WHERE tournament_stage_id = _final_stage_id AND path = 'WB' + ) + AND fm.winning_lineup_id IS NOT NULL + AND tm.winning_lineup_id IS NOT NULL; + + -- Otherwise placement comes from v_team_stage_results, the single source + -- of truth for tournament ordering (DE uses elimination round, RR/Swiss/SE -- use wins-based tiebreakers). The view's `placement` column shares ranks -- on ties, which lets us suppress the bronze when 3rd is contested -- (e.g. SingleElim with no third_place_match → both SF losers tied). -- Separate scalar selects keep this off `min(uuid)`, which Postgres lacks. - SELECT tournament_team_id INTO _winning_team_id - FROM public.v_team_stage_results - WHERE tournament_stage_id = _final_stage_id - AND placement = 1 - ORDER BY tournament_team_id::text - LIMIT 1; + IF _winning_team_id IS NULL THEN + SELECT tournament_team_id INTO _winning_team_id + FROM public.v_team_stage_results + WHERE tournament_stage_id = _final_stage_id + AND placement = 1 + ORDER BY tournament_team_id::text + LIMIT 1; - SELECT tournament_team_id INTO _runner_up_team_id - FROM public.v_team_stage_results - WHERE tournament_stage_id = _final_stage_id - AND placement = 2 - ORDER BY tournament_team_id::text - LIMIT 1; + SELECT tournament_team_id INTO _runner_up_team_id + FROM public.v_team_stage_results + WHERE tournament_stage_id = _final_stage_id + AND placement = 2 + ORDER BY tournament_team_id::text + LIMIT 1; - SELECT tournament_team_id INTO _third_team_id - FROM public.v_team_stage_results - WHERE tournament_stage_id = _final_stage_id - AND placement = 3 - AND ( - SELECT COUNT(*) FROM public.v_team_stage_results - WHERE tournament_stage_id = _final_stage_id AND placement = 3 - ) = 1 - LIMIT 1; + SELECT tournament_team_id INTO _third_team_id + FROM public.v_team_stage_results + WHERE tournament_stage_id = _final_stage_id + AND placement = 3 + AND ( + SELECT COUNT(*) FROM public.v_team_stage_results + WHERE tournament_stage_id = _final_stage_id AND placement = 3 + ) = 1 + LIMIT 1; + END IF; _award_third := _third_team_id IS NOT NULL; diff --git a/hasura/functions/tournaments/find_adjacent_swiss_team.sql b/hasura/functions/tournaments/find_adjacent_swiss_team.sql index 74070068..32a3fe45 100644 --- a/hasura/functions/tournaments/find_adjacent_swiss_team.sql +++ b/hasura/functions/tournaments/find_adjacent_swiss_team.sql @@ -10,19 +10,23 @@ AS $$ DECLARE adjacent_team_id uuid; pool_record RECORD; - preferred_pool RECORD; - fallback_pool RECORD; + -- Track candidate pools with scalars: a RECORD assigned NULL stays + -- "not assigned" in plpgsql, and IS [NOT] NULL on it raises + -- 'record is not assigned yet' the first time an odd pool needs pairing. + preferred_team_ids uuid[]; + preferred_wins int; + preferred_losses int; + fallback_team_ids uuid[]; + fallback_wins int; + fallback_losses int; BEGIN -- Strategy: When pairing with adjacent pool, prefer: -- 1. Worst result with same wins (more losses) - e.g., if we have (1W, 1L), prefer (1W, 2L) -- 2. Best result with same losses (more wins) - e.g., if we have (1W, 1L), prefer (2W, 1L) -- This pairs worst 1-win teams with best 1-loss teams - - preferred_pool := NULL; - fallback_pool := NULL; - + -- Check all pools for adjacent ones - FOR pool_record IN + FOR pool_record IN SELECT * FROM get_swiss_team_pools(_stage_id, _exclude_team_ids) WHERE (wins, losses) != (_wins, _losses) -- Different pool AND team_count > 0 -- Has teams @@ -31,44 +35,49 @@ BEGIN -- This means: (wins_diff = 1 AND losses_diff = 0) OR (wins_diff = 0 AND losses_diff = 1) IF (ABS(pool_record.wins - _wins) = 1 AND pool_record.losses = _losses) OR (pool_record.wins = _wins AND ABS(pool_record.losses - _losses) = 1) THEN - + -- Priority 1: Same wins, more losses (worst result with same wins) -- e.g., if we have (1W, 1L), prefer (1W, 2L) IF pool_record.wins = _wins AND pool_record.losses > _losses THEN - IF preferred_pool IS NULL OR pool_record.losses > preferred_pool.losses THEN - preferred_pool := pool_record; + IF preferred_team_ids IS NULL OR pool_record.losses > preferred_losses THEN + preferred_team_ids := pool_record.team_ids; + preferred_wins := pool_record.wins; + preferred_losses := pool_record.losses; END IF; -- Priority 2: Same losses, more wins (best result with same losses) -- e.g., if we have (1W, 1L), prefer (2W, 1L) ELSIF pool_record.losses = _losses AND pool_record.wins > _wins THEN - IF preferred_pool IS NULL OR pool_record.wins > preferred_pool.wins THEN - preferred_pool := pool_record; + IF preferred_team_ids IS NULL OR pool_record.wins > preferred_wins THEN + preferred_team_ids := pool_record.team_ids; + preferred_wins := pool_record.wins; + preferred_losses := pool_record.losses; END IF; -- Fallback: Other adjacent pools (same wins fewer losses, or same losses fewer wins) ELSE - IF fallback_pool IS NULL THEN - fallback_pool := pool_record; + IF fallback_team_ids IS NULL THEN + fallback_team_ids := pool_record.team_ids; + fallback_wins := pool_record.wins; + fallback_losses := pool_record.losses; END IF; END IF; END IF; END LOOP; - + -- Use preferred pool if available, otherwise fallback - IF preferred_pool IS NOT NULL AND array_length(preferred_pool.team_ids, 1) > 0 THEN - adjacent_team_id := preferred_pool.team_ids[1]; - RAISE NOTICE ' Found preferred adjacent team % from pool (W:% L:%) for pool (W:% L:%)', - adjacent_team_id, preferred_pool.wins, preferred_pool.losses, _wins, _losses; + IF preferred_team_ids IS NOT NULL AND array_length(preferred_team_ids, 1) > 0 THEN + adjacent_team_id := preferred_team_ids[1]; + RAISE NOTICE ' Found preferred adjacent team % from pool (W:% L:%) for pool (W:% L:%)', + adjacent_team_id, preferred_wins, preferred_losses, _wins, _losses; RETURN adjacent_team_id; - ELSIF fallback_pool IS NOT NULL AND array_length(fallback_pool.team_ids, 1) > 0 THEN - adjacent_team_id := fallback_pool.team_ids[1]; - RAISE NOTICE ' Found fallback adjacent team % from pool (W:% L:%) for pool (W:% L:%)', - adjacent_team_id, fallback_pool.wins, fallback_pool.losses, _wins, _losses; + ELSIF fallback_team_ids IS NOT NULL AND array_length(fallback_team_ids, 1) > 0 THEN + adjacent_team_id := fallback_team_ids[1]; + RAISE NOTICE ' Found fallback adjacent team % from pool (W:% L:%) for pool (W:% L:%)', + adjacent_team_id, fallback_wins, fallback_losses, _wins, _losses; RETURN adjacent_team_id; END IF; - + -- If no adjacent pool found, return NULL (shouldn't happen in practice) RAISE WARNING 'No adjacent pool found for (W:% L:%)', _wins, _losses; RETURN NULL; END; $$; - diff --git a/hasura/functions/tournaments/reset_tournament_match.sql b/hasura/functions/tournaments/reset_tournament_match.sql index 3e396a4c..4f5f6080 100644 --- a/hasura/functions/tournaments/reset_tournament_match.sql +++ b/hasura/functions/tournaments/reset_tournament_match.sql @@ -7,12 +7,15 @@ AS $$ DECLARE slot_position int; BEGIN + -- Same feeder ordering as assign_team_to_bracket_slot, including the + -- WB-before-LB key that disambiguates the grand final's two feeders. SELECT ranked.pos INTO slot_position FROM ( SELECT f.id, row_number() OVER ( ORDER BY CASE WHEN f.loser_parent_bracket_id = _target_bracket_id THEN 0 ELSE 1 END, + CASE WHEN f.path = 'LB' THEN 1 ELSE 0 END, f.round, f.match_number ) AS pos diff --git a/hasura/functions/tournaments/update_tournament_bracket.sql b/hasura/functions/tournaments/update_tournament_bracket.sql index a9b7a105..2ec79e62 100644 --- a/hasura/functions/tournaments/update_tournament_bracket.sql +++ b/hasura/functions/tournaments/update_tournament_bracket.sql @@ -21,6 +21,15 @@ BEGIN RETURN; END IF; + -- Serialize result processing per stage. Two matches reported at the same + -- moment otherwise interleave row locks in opposite orders (own bracket -> + -- shared parent -> standings cache -> start-time sweep) and deadlock; + -- Postgres then aborts one transaction and that match result is lost. + -- Locking the stage row first gives every reporter the same lock order. + PERFORM 1 FROM tournament_stages + WHERE id = bracket.tournament_stage_id + FOR UPDATE; + IF match.winning_lineup_id = match.lineup_1_id THEN winning_team_id = bracket.tournament_team_id_1; losing_team_id = bracket.tournament_team_id_2; diff --git a/hasura/triggers/player_kills.sql b/hasura/triggers/player_kills.sql index 5e7b0e76..9fd31ea0 100644 --- a/hasura/triggers/player_kills.sql +++ b/hasura/triggers/player_kills.sql @@ -12,12 +12,15 @@ BEGIN SET kill_count = player_kills_by_weapon.kill_count + 1; - -- attacker: kills + headshots - INSERT INTO player_stats (player_steam_id, kills, headshots) + -- attacker: kills + headshots. The percentage must also be set on the + -- very first kill (the insert path), not just on conflict, or a player's + -- opening headshot reads as 0% until their second kill. + INSERT INTO player_stats (player_steam_id, kills, headshots, headshot_percentage) VALUES ( NEW.attacker_steam_id, 1, - CASE WHEN NEW.headshot THEN 1 ELSE 0 END + CASE WHEN NEW.headshot THEN 1 ELSE 0 END, + CASE WHEN NEW.headshot THEN 1 ELSE 0 END::float ) ON CONFLICT (player_steam_id) DO UPDATE SET @@ -43,12 +46,13 @@ BEGIN FROM matches m WHERE m.id = NEW.match_id; IF _season_id IS NOT NULL THEN - INSERT INTO player_season_stats (player_steam_id, season_id, kills, headshots) + INSERT INTO player_season_stats (player_steam_id, season_id, kills, headshots, headshot_percentage) VALUES ( NEW.attacker_steam_id, _season_id, 1, - CASE WHEN NEW.headshot THEN 1 ELSE 0 END + CASE WHEN NEW.headshot THEN 1 ELSE 0 END, + CASE WHEN NEW.headshot THEN 1 ELSE 0 END::float ) ON CONFLICT (player_steam_id, season_id) DO UPDATE SET diff --git a/package.json b/package.json index b0bcf051..03268b63 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", + "test:unit": "jest --testPathPatterns 'src/.*\\.spec\\.ts$' --runInBand", + "test:sql": "jest --testPathPatterns 'test/.*\\.spec\\.ts$' --runInBand", + "typecheck:tests": "tsc -p tsconfig.spec.json", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", diff --git a/src/postgres/postgres.service.ts b/src/postgres/postgres.service.ts index b86b498f..c2fd6fe4 100644 --- a/src/postgres/postgres.service.ts +++ b/src/postgres/postgres.service.ts @@ -27,9 +27,11 @@ export class PostgresService { bindings?: Array< | string | number + | boolean | Date | bigint | Buffer + | null | Array | Array | Array diff --git a/test/concurrency.spec.ts b/test/concurrency.spec.ts new file mode 100644 index 00000000..80a086e3 --- /dev/null +++ b/test/concurrency.spec.ts @@ -0,0 +1,192 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +type PoolClient = { + query(sql: string, params?: unknown[]): Promise<{ rows: unknown[] }>; + release(): void; +}; + +// Exercises the concurrency claims in the SQL: verify_map_veto_pick and +// assign_team_to_bracket_slot both take row locks (FOR UPDATE) so simultaneous +// writers serialize instead of double-applying. These tests race two real +// connections against each other. +describe("concurrency (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + const pool = () => + ( + postgres as unknown as { + pool: { connect(): Promise }; + } + ).pool; + + beforeAll(async () => { + db = await bootMigratedDb("ConcurrencyTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199970000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + it("simultaneous veto picks serialize: exactly one Ban lands per turn", async () => { + const { poolId, mapIds } = await fx.mapPool(3); + const match = await fx.match({ mapVeto: true, mapPoolId: poolId }); + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + match.id, + ]); // redirected to Veto + + // Two clients race the same first-turn Ban with different maps. + const clientA = await pool().connect(); + const clientB = await pool().connect(); + try { + const results = await Promise.allSettled([ + clientA.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) + VALUES ($1, 'Ban', $2, $3)`, + [match.id, match.lineup_1_id, mapIds[0]], + ), + clientB.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) + VALUES ($1, 'Ban', $2, $3)`, + [match.id, match.lineup_1_id, mapIds[1]], + ), + ]); + + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter( + (r) => r.status === "rejected", + ) as Array; + expect(fulfilled.length).toBe(1); + expect(rejected.length).toBe(1); + // The loser sees the advanced turn, not a corrupted state. + expect(String(rejected[0].reason)).toMatch(/Expected other lineup/i); + + const picks = await postgres.query>( + "SELECT type FROM match_map_veto_picks WHERE match_id = $1", + [match.id], + ); + expect(picks.length).toBe(1); + } finally { + clientA.release(); + clientB.release(); + } + }); + + it("simultaneous semifinal results both land in the final, in distinct slots", async () => { + const t = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }], + 4, + ); + const semis = (await tfx.getBrackets(t.stageIds[0])).filter( + (b) => b.round === 1, + ); + + const clientA = await pool().connect(); + const clientB = await pool().connect(); + try { + const results = await Promise.allSettled([ + clientA.query( + "UPDATE matches SET winning_lineup_id = lineup_1_id WHERE id = $1", + [semis[0].match_id], + ), + clientB.query( + "UPDATE matches SET winning_lineup_id = lineup_2_id WHERE id = $1", + [semis[1].match_id], + ), + ]); + const failures = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected", + ); + expect(failures.map((f) => String(f.reason))).toEqual([]); + } finally { + clientA.release(); + clientB.release(); + } + + const final = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.round === 2, + )!; + // Both winners present, in different slots, with a match scheduled — the + // exact invariant the grand-final slot collision violated. + expect(final.tournament_team_id_1).toBe(semis[0].tournament_team_id_1); + expect(final.tournament_team_id_2).toBe(semis[1].tournament_team_id_2); + expect(final.match_id).not.toBeNull(); + }); + + it("racing the same waitlist promotion never over-fills a draft", async () => { + const host = await fx.player(); + const [draft] = await postgres.query< + Array<{ id: string; capacity: number }> + >( + `INSERT INTO draft_games (host_steam_id, type) VALUES ($1, 'Wingman') RETURNING id, capacity`, + [host], + ); + // Fill to capacity, then two waitlisted players. + const members: Array = []; + for (let i = 0; i < Number(draft.capacity); i++) { + const p = await fx.player(); + members.push(p); + await postgres.query( + `INSERT INTO draft_game_players (draft_game_id, steam_id, status) VALUES ($1, $2, 'Accepted')`, + [draft.id, p], + ); + } + for (let i = 0; i < 2; i++) { + await postgres.query( + `INSERT INTO draft_game_players (draft_game_id, steam_id, status) VALUES ($1, $2, 'Waitlist')`, + [draft.id, await fx.player()], + ); + } + + // Two accepted members leave at the same moment. + const clientA = await pool().connect(); + const clientB = await pool().connect(); + try { + await Promise.allSettled([ + clientA.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, members[0]], + ), + clientB.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, members[1]], + ), + ]); + } finally { + clientA.release(); + clientB.release(); + } + + const [counts] = await postgres.query< + Array<{ accepted: string; waitlisted: string }> + >( + `SELECT count(*) FILTER (WHERE status = 'Accepted') AS accepted, + count(*) FILTER (WHERE status = 'Waitlist') AS waitlisted + FROM draft_game_players WHERE draft_game_id = $1`, + [draft.id], + ); + expect(Number(counts.accepted)).toBe(Number(draft.capacity)); + expect(Number(counts.waitlisted)).toBe(0); + }); +}); diff --git a/test/draft-membership.spec.ts b/test/draft-membership.spec.ts new file mode 100644 index 00000000..7abf3de3 --- /dev/null +++ b/test/draft-membership.spec.ts @@ -0,0 +1,175 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; + +// Exercises the draft-room membership triggers (draft_game_players): join +// status resolution (host / approval / capacity waitlist), single-draft +// membership on accept, waitlist promotion, host succession, and draft +// teardown when players leave. +describe("draft room membership (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("DraftMembershipTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199800000000n); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM draft_games"); + await postgres.query("DELETE FROM players"); + }); + + // Wingman drafts keep capacity at 4. + const createDraft = async ({ requireApproval = false } = {}) => { + const host = await fx.player(); + const [draft] = await postgres.query< + Array<{ id: string; capacity: number }> + >( + `INSERT INTO draft_games (host_steam_id, type, require_approval) + VALUES ($1, 'Wingman', $2) RETURNING id, capacity`, + [host, requireApproval], + ); + // The host joins their own room. + await joinAs(draft.id, host); + return { id: draft.id, host, capacity: Number(draft.capacity) }; + }; + + const joinAs = async (draftId: string, steam: string) => { + await runAsUser(postgres, steam, "user", (query) => + query( + "INSERT INTO draft_game_players (draft_game_id, steam_id) VALUES ($1, $2)", + [draftId, steam], + ), + ); + return steam; + }; + + const membership = (draftId: string) => + postgres.query< + Array<{ steam_id: string; status: string; elo_snapshot: number }> + >( + `SELECT steam_id, status, elo_snapshot FROM draft_game_players + WHERE draft_game_id = $1 ORDER BY joined_at`, + [draftId], + ); + + const statusOf = async (draftId: string, steam: string) => + (await membership(draftId)).find((m) => m.steam_id === steam)?.status; + + it("resolves join statuses: host accepted, open joins accepted, approval rooms request", async () => { + const open = await createDraft(); + expect(await statusOf(open.id, open.host)).toBe("Accepted"); + + const joiner = await joinAs(open.id, await fx.player()); + expect(await statusOf(open.id, joiner)).toBe("Accepted"); + + const gated = await createDraft({ requireApproval: true }); + const requester = await joinAs(gated.id, await fx.player()); + expect(await statusOf(gated.id, requester)).toBe("Requested"); + + // The host adding someone bypasses approval. + const invited = await fx.player(); + await runAsUser(postgres, gated.host, "user", (query) => + query( + "INSERT INTO draft_game_players (draft_game_id, steam_id) VALUES ($1, $2)", + [gated.id, invited], + ), + ); + expect(await statusOf(gated.id, invited)).toBe("Accepted"); + }); + + it("snapshots a default 5000 elo for unrated joiners", async () => { + const draft = await createDraft(); + const joiner = await joinAs(draft.id, await fx.player()); + const rows = await membership(draft.id); + expect( + Number(rows.find((m) => m.steam_id === joiner)!.elo_snapshot), + ).toBe(5000); + }); + + it("waitlists joiners beyond capacity and promotes them as seats free up", async () => { + const draft = await createDraft(); + const members = [draft.host]; + for (let i = 1; i < draft.capacity; i++) { + members.push(await joinAs(draft.id, await fx.player())); + } + const latecomer = await joinAs(draft.id, await fx.player()); + expect(await statusOf(draft.id, latecomer)).toBe("Waitlist"); + + // A seat frees up: the waitlisted player is promoted. + await postgres.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, members[1]], + ); + expect(await statusOf(draft.id, latecomer)).toBe("Accepted"); + }); + + it("accepting into a new draft pulls the player out of other open drafts", async () => { + const first = await createDraft(); + const second = await createDraft(); + const drifter = await fx.player(); + + await joinAs(first.id, drifter); + await joinAs(second.id, drifter); + + expect(await statusOf(first.id, drifter)).toBeUndefined(); + expect(await statusOf(second.id, drifter)).toBe("Accepted"); + }); + + it("the host leaving hands the room to the oldest accepted member", async () => { + const draft = await createDraft(); + const heir = await joinAs(draft.id, await fx.player()); + await joinAs(draft.id, await fx.player()); + + await postgres.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, draft.host], + ); + + const [game] = await postgres.query>( + "SELECT host_steam_id FROM draft_games WHERE id = $1", + [draft.id], + ); + expect(game.host_steam_id).toBe(heir); + }); + + it("the last player leaving dissolves the draft", async () => { + const draft = await createDraft(); + await postgres.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1", + [draft.id], + ); + const rows = await postgres.query>( + "SELECT 1 FROM draft_games WHERE id = $1", + [draft.id], + ); + expect(rows.length).toBe(0); + }); + + it("leaving a draft that has started tears the whole draft down", async () => { + const draft = await createDraft(); + const quitter = await joinAs(draft.id, await fx.player()); + await postgres.query( + "UPDATE draft_games SET status = 'Drafting' WHERE id = $1", + [draft.id], + ); + + await postgres.query( + "DELETE FROM draft_game_players WHERE draft_game_id = $1 AND steam_id = $2", + [draft.id, quitter], + ); + + const rows = await postgres.query>( + "SELECT 1 FROM draft_games WHERE id = $1", + [draft.id], + ); + expect(rows.length).toBe(0); + }); +}); diff --git a/test/elo.spec.ts b/test/elo.spec.ts new file mode 100644 index 00000000..d71e3b18 --- /dev/null +++ b/test/elo.spec.ts @@ -0,0 +1,296 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the ELO engine (generate_player_elo_for_match / +// get_player_elo_for_match): the 5000 baseline, rating chaining across +// matches, series-differential scaling, recompute idempotency, the +// source/winner guards, per-season ladder isolation, the tournament track, +// and the loss-protection transform for strong performers on losing teams. +describe("ELO engine (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + beforeAll(async () => { + db = await bootMigratedDb("EloTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199500000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + await postgres.query("DELETE FROM seasons"); + await postgres.query( + "DELETE FROM settings WHERE name = 'public.seasons_enabled'", + ); + }); + + // A finished 1v1: Duel needs one player per lineup, keeping team averages + // equal to the player's own rating. + const duel = async ( + playerA: string, + playerB: string, + { + winner = "a", + endedDaysAgo = 1, + bestOf = 1, + }: { winner?: "a" | "b"; endedDaysAgo?: number; bestOf?: number } = {}, + ) => { + const match = await fx.match({ + type: "Duel", + bestOf, + mapPoolId: + bestOf > 1 ? (await fx.mapPool(bestOf)).poolId : undefined, + }); + await fx.lineupPlayer(match.lineup_1_id, playerA); + await fx.lineupPlayer(match.lineup_2_id, playerB); + await postgres.query( + `UPDATE matches SET winning_lineup_id = ${ + winner === "a" ? "lineup_1_id" : "lineup_2_id" + } WHERE id = $1`, + [match.id], + ); + await postgres.query( + `UPDATE matches SET ended_at = now() - make_interval(days => $2) WHERE id = $1`, + [match.id, endedDaysAgo], + ); + return match; + }; + + const generate = async (matchId: string) => { + const [row] = await postgres.query< + Array<{ generate_player_elo_for_match: number }> + >("SELECT generate_player_elo_for_match($1)", [matchId]); + return Number(row.generate_player_elo_for_match); + }; + + type EloRow = { + steam_id: string; + current: number; + change: number; + actual_score: number; + expected_score: number; + series_multiplier: number; + performance_multiplier: number; + season_id: string | null; + }; + + const eloRows = (matchId: string) => + postgres.query>( + `SELECT steam_id, current, change, actual_score, expected_score, + series_multiplier, performance_multiplier, season_id + FROM player_elo WHERE match_id = $1 ORDER BY steam_id`, + [matchId], + ); + + it("rates both players off the 5000 baseline with symmetric expectations", async () => { + const [a, b] = await fx.players(2); + const match = await duel(a, b); + + expect(await generate(match.id)).toBe(2); + + const rows = await eloRows(match.id); + const winner = rows.find((r) => r.steam_id === a)!; + const loser = rows.find((r) => r.steam_id === b)!; + + expect(winner.expected_score).toBeCloseTo(0.5); + expect(loser.expected_score).toBeCloseTo(0.5); + expect(Number(winner.change)).toBeGreaterThan(0); + expect(Number(loser.change)).toBeLessThan(0); + expect(Number(winner.current)).toBe(5000 + Number(winner.change)); + expect(Number(loser.current)).toBe(5000 + Number(loser.change)); + }); + + it("chains each match off the previous rating", async () => { + const [a, b] = await fx.players(2); + const first = await duel(a, b, { endedDaysAgo: 3 }); + await generate(first.id); + const [firstWinner] = (await eloRows(first.id)).filter( + (r) => r.steam_id === a, + ); + + const second = await duel(a, b, { endedDaysAgo: 2 }); + await generate(second.id); + const [secondWinner] = (await eloRows(second.id)).filter( + (r) => r.steam_id === a, + ); + + expect(Number(secondWinner.current)).toBe( + Number(firstWinner.current) + Number(secondWinner.change), + ); + // The higher-rated player is now expected to win. + expect(secondWinner.expected_score).toBeGreaterThan(0.5); + }); + + it("scales the change by the series map differential", async () => { + const [a, b] = await fx.players(2); + const sweep = await duel(a, b, { bestOf: 3, endedDaysAgo: 4 }); + // 2-0 sweep: both decided maps to lineup 1. + await postgres.query( + `UPDATE match_maps SET winning_lineup_id = $2 + WHERE match_id = $1 AND "order" <= 2`, + [sweep.id, sweep.lineup_1_id], + ); + await generate(sweep.id); + const sweepRows = await eloRows(sweep.id); + expect( + sweepRows.every((r) => Number(r.series_multiplier) === 2), + ).toBe(true); + + const [c, d] = await fx.players(2); + const close = await duel(c, d, { bestOf: 3, endedDaysAgo: 3 }); + await postgres.query( + `UPDATE match_maps SET winning_lineup_id = $2 WHERE match_id = $1 AND "order" <= 2`, + [close.id, close.lineup_1_id], + ); + await postgres.query( + `UPDATE match_maps SET winning_lineup_id = $2 WHERE match_id = $1 AND "order" = 3`, + [close.id, close.lineup_2_id], + ); + await generate(close.id); + const closeRows = await eloRows(close.id); + expect( + closeRows.every((r) => Number(r.series_multiplier) === 1), + ).toBe(true); + + // Same baseline, same outcome — the sweep moves ratings further. + const sweepWinner = sweepRows.find((r) => r.steam_id === a)!; + const closeWinner = closeRows.find((r) => r.steam_id === c)!; + expect(Number(sweepWinner.change)).toBeGreaterThan( + Number(closeWinner.change), + ); + }); + + it("recomputes idempotently: one row set per player per match", async () => { + const [a, b] = await fx.players(2); + const match = await duel(a, b); + + await generate(match.id); + const firstRun = await eloRows(match.id); + await generate(match.id); + const secondRun = await eloRows(match.id); + + expect(secondRun.length).toBe(2); + expect(secondRun).toEqual(firstRun); + }); + + it("skips external and undecided matches", async () => { + const [a, b] = await fx.players(2); + + const undecided = await fx.match({ type: "Duel" }); + await fx.lineupPlayer(undecided.lineup_1_id, a); + await fx.lineupPlayer(undecided.lineup_2_id, b); + expect(await generate(undecided.id)).toBe(0); + + const external = await duel(a, b); + await postgres.query("UPDATE matches SET source = 'faceit' WHERE id = $1", [ + external.id, + ]); + expect(await generate(external.id)).toBe(0); + expect((await eloRows(external.id)).length).toBe(0); + }); + + it("keeps each season's ladder independent", async () => { + await fx.enableSeasons(); + const seasonOne = await fx.season("2025-01-01", "2025-03-01"); + const seasonTwo = await fx.season("2025-03-01", "2025-06-01"); + const [a, b] = await fx.players(2); + + const inSeasonOne = await duel(a, b); + await postgres.query( + "UPDATE matches SET ended_at = '2025-02-01' WHERE id = $1", + [inSeasonOne.id], + ); + await generate(inSeasonOne.id); + + const inSeasonTwo = await duel(a, b); + await postgres.query( + "UPDATE matches SET ended_at = '2025-04-01' WHERE id = $1", + [inSeasonTwo.id], + ); + await generate(inSeasonTwo.id); + + const seasonOneRows = await eloRows(inSeasonOne.id); + const seasonTwoRows = await eloRows(inSeasonTwo.id); + + expect(seasonOneRows.every((r) => r.season_id === seasonOne)).toBe(true); + expect(seasonTwoRows.every((r) => r.season_id === seasonTwo)).toBe(true); + + // The new season starts from the baseline again, not from season one's ladder. + const winnerTwo = seasonTwoRows.find((r) => r.steam_id === a)!; + expect(Number(winnerTwo.current)).toBe(5000 + Number(winnerTwo.change)); + expect(winnerTwo.expected_score).toBeCloseTo(0.5); + }); + + it("rates tournament matches on their own season-independent track", async () => { + await fx.enableSeasons(); + await fx.season("2025-01-01", null); + + const t = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }], + 4, + ); + const semi = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.round === 1, + )!; + await tfx.winMatch(semi.match_id!); + + expect(await generate(semi.match_id!)).toBeGreaterThan(0); + const rows = await postgres.query>( + "SELECT season_id FROM player_elo WHERE match_id = $1", + [semi.match_id], + ); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.season_id === null)).toBe(true); + }); + + it("protects strong performers on losing teams", async () => { + // Two identical 1v1 losses; in the second, the loser at least got kills + // and damage in. The loss-transform maps better impact to a smaller cut. + const [a, b] = await fx.players(2); + const silent = await duel(a, b, { endedDaysAgo: 4 }); + await generate(silent.id); + const silentLoser = (await eloRows(silent.id)).find( + (r) => r.steam_id === b, + )!; + + const [c, d] = await fx.players(2); + const fought = await duel(c, d, { endedDaysAgo: 3 }); + const [map] = await postgres.query>( + `INSERT INTO match_maps (match_id, map_id, "order") + SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1 RETURNING id`, + [fought.id], + ); + const ctx = { matchId: fought.id, mapId: map.id }; + await fx.kill(ctx, d, c, { time: new Date().toISOString() }); + await fx.damage(ctx, d, c, 100); + await generate(fought.id); + const fightingLoser = (await eloRows(fought.id)).find( + (r) => r.steam_id === d, + )!; + + expect(fightingLoser.performance_multiplier).toBeLessThan( + silentLoser.performance_multiplier, + ); + expect(Math.abs(Number(fightingLoser.change))).toBeLessThan( + Math.abs(Number(silentLoser.change)), + ); + }); +}); diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts new file mode 100644 index 00000000..71f26c50 --- /dev/null +++ b/test/fixtures.spec.ts @@ -0,0 +1,104 @@ +import * as fs from "fs"; +import * as path from "path"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Guards the LOAD_FIXTURES=true dev seeding path (hasura/fixtures). The +// fixture scripts disable triggers and write straight to the tables, so +// nothing else catches them drifting from the live schema — this suite +// applies them against a freshly migrated database exactly the way +// HasuraService.setup() does (cleanup.sql first, then fixtures.sql). +describe("dev fixtures (hasura/fixtures)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + + const FIXTURES_DIR = path.resolve("./hasura/fixtures"); + + const applyFile = (file: string) => + postgres.query(fs.readFileSync(path.join(FIXTURES_DIR, file), "utf8")); + + const count = async (sql: string) => + Number( + (await postgres.query>(`SELECT count(*) AS c FROM ${sql}`))[0].c, + ); + + beforeAll(async () => { + db = await bootMigratedDb("FixturesTest"); + postgres = db.postgres; + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + it("applies cleanly on a fresh install and seeds the documented dataset", async () => { + await applyFile("cleanup.sql"); + await applyFile("fixtures.sql"); + + // The headline numbers from the fixture header: ~40 players, 8 teams, + // ~143 matches, 4 tournaments. + expect(await count("players")).toBeGreaterThanOrEqual(40); + expect(await count("teams")).toBe(8); + expect(await count("matches")).toBeGreaterThanOrEqual(100); + expect(await count("tournaments")).toBe(4); + expect(await count("match_map_rounds")).toBeGreaterThan(0); + expect(await count("player_kills")).toBeGreaterThan(0); + expect(await count("seasons")).toBeGreaterThan(0); + + const [flag] = await postgres.query>( + "SELECT value FROM settings WHERE name = 'dev.fixtures_loaded'", + ); + expect(flag?.value).toBe("true"); + }, 180_000); + + it("leaves every disabled trigger re-enabled", async () => { + // tgenabled 'D' would mean a fixture script disabled a trigger and never + // restored it — silently turning off production behavior in dev. + const disabled = await postgres.query>( + `SELECT tgname FROM pg_trigger WHERE tgenabled = 'D'`, + ); + expect(disabled).toEqual([]); + }); + + it("cleanup removes the fixture data and the loaded flag", async () => { + await applyFile("cleanup.sql"); + + expect( + await count( + "players WHERE steam_id BETWEEN 76561198000000001 AND 76561198000000040", + ), + ).toBe(0); + expect(await count("teams")).toBe(0); + expect(await count("tournaments")).toBe(0); + expect( + await count("settings WHERE name = 'dev.fixtures_loaded'"), + ).toBe(0); + }, 120_000); + + it("is idempotent: cleanup + fixtures re-applies without error", async () => { + await applyFile("cleanup.sql"); + await applyFile("fixtures.sql"); + expect(await count("teams")).toBe(8); + }, 180_000); + + it("warm re-apply: setup() re-runs every SQL file over a data-full database", async () => { + // Production upgrades run setup() against a live database. Deleting the + // stored file digests forces every enum/function/view/trigger file to + // re-apply (migrations stay applied via schema_migrations) — catching + // files that only work on an empty schema, the regression class the + // cold-start suite can't see. + const before = await count("teams"); + expect(before).toBe(8); + + await postgres.query("DELETE FROM settings WHERE name LIKE 'hasura/%'"); + await db.hasura.setup(); + + expect(await count("teams")).toBe(before); + expect(await count("matches")).toBeGreaterThan(0); + + const disabled = await postgres.query>( + `SELECT tgname FROM pg_trigger WHERE tgenabled = 'D'`, + ); + expect(disabled).toEqual([]); + }, 300_000); +}); diff --git a/test/lobbies.spec.ts b/test/lobbies.spec.ts new file mode 100644 index 00000000..f9f80136 --- /dev/null +++ b/test/lobbies.spec.ts @@ -0,0 +1,137 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; + +// Exercises the lobby triggers: creator bootstrap (tai_lobbies), single-lobby +// membership on accept (taiu_lobby_players), and captain succession / lobby +// teardown on leave (tad_lobby_players). +describe("lobbies (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("LobbiesTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199100000000n); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM lobbies"); + await postgres.query("DELETE FROM players"); + }); + + const seedPlayer = () => fx.player(); + + // Lobby creation reads the creator from the Hasura session. + const createLobby = async (creator: string) => + runAsUser(postgres, creator, "user", async (query) => { + const [row] = (await query( + "INSERT INTO lobbies (access) VALUES ('Private') RETURNING id", + )) as Array<{ id: string }>; + return row.id; + }); + + const lobbyPlayers = (lobbyId: string) => + postgres.query< + Array<{ steam_id: string; captain: boolean; status: string }> + >( + "SELECT steam_id, captain, status FROM lobby_players WHERE lobby_id = $1 ORDER BY steam_id", + [lobbyId], + ); + + const invite = (lobbyId: string, steam: string) => + postgres.query( + "INSERT INTO lobby_players (lobby_id, steam_id, status) VALUES ($1, $2, 'Invited')", + [lobbyId, steam], + ); + + const accept = (lobbyId: string, steam: string) => + postgres.query( + "UPDATE lobby_players SET status = 'Accepted' WHERE lobby_id = $1 AND steam_id = $2", + [lobbyId, steam], + ); + + const leave = (lobbyId: string, steam: string) => + postgres.query( + "DELETE FROM lobby_players WHERE lobby_id = $1 AND steam_id = $2", + [lobbyId, steam], + ); + + it("creating a lobby enrolls the creator as accepted captain", async () => { + const creator = await seedPlayer(); + const lobbyId = await createLobby(creator); + + const players = await lobbyPlayers(lobbyId); + expect(players).toEqual([ + { steam_id: creator, captain: true, status: "Accepted" }, + ]); + }); + + it("accepting an invite pulls the player out of every other lobby", async () => { + const creatorA = await seedPlayer(); + const creatorB = await seedPlayer(); + const drifter = await seedPlayer(); + const lobbyA = await createLobby(creatorA); + const lobbyB = await createLobby(creatorB); + + await invite(lobbyA, drifter); + await accept(lobbyA, drifter); + + await invite(lobbyB, drifter); + await accept(lobbyB, drifter); + + expect( + (await lobbyPlayers(lobbyA)).map((p) => p.steam_id), + ).not.toContain(drifter); + expect((await lobbyPlayers(lobbyB)).map((p) => p.steam_id)).toContain( + drifter, + ); + }); + + it("the captain leaving promotes the next accepted player", async () => { + const creator = await seedPlayer(); + const mate = await seedPlayer(); + const lobbyId = await createLobby(creator); + await invite(lobbyId, mate); + await accept(lobbyId, mate); + + await leave(lobbyId, creator); + + const players = await lobbyPlayers(lobbyId); + expect(players.length).toBe(1); + expect(players[0].steam_id).toBe(mate); + expect(players[0].captain).toBe(true); + }); + + it("the last accepted player leaving dissolves the lobby", async () => { + const creator = await seedPlayer(); + const lobbyId = await createLobby(creator); + + await leave(lobbyId, creator); + + const lobbies = await postgres.query>( + "SELECT 1 FROM lobbies WHERE id = $1", + [lobbyId], + ); + expect(lobbies.length).toBe(0); + }); + + it("a pending invitee leaving does not dethrone the captain", async () => { + const creator = await seedPlayer(); + const invitee = await seedPlayer(); + const lobbyId = await createLobby(creator); + await invite(lobbyId, invitee); + + await leave(lobbyId, invitee); + + const players = await lobbyPlayers(lobbyId); + expect(players).toEqual([ + { steam_id: creator, captain: true, status: "Accepted" }, + ]); + }); +}); diff --git a/test/map-veto.spec.ts b/test/map-veto.spec.ts new file mode 100644 index 00000000..e2ca5079 --- /dev/null +++ b/test/map-veto.spec.ts @@ -0,0 +1,258 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the map veto SQL: get_map_veto_pattern / get_map_veto_type / +// get_map_veto_picking_lineup_id, verify_map_veto_pick enforcement, and +// create_match_map_from_veto (map materialization, side assignment, the +// auto-inserted Decider, and going Live when the veto completes). +describe("map veto (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("MapVetoTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + }); + + // A match sitting in Veto: single viable region (pre-selected on insert) so + // only the map veto is outstanding when we push it towards Live. + const createVetoMatch = async (bestOf: number, poolSize: number) => { + const { poolId, mapIds } = await fx.mapPool(poolSize); + const match = await fx.match({ bestOf, mapVeto: true, mapPoolId: poolId }); + // tbu_matches redirects Live to Veto while maps are missing. + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + match.id, + ]); + return { ...match, mapIds }; + }; + + const vetoState = async (matchId: string) => { + const [row] = await postgres.query< + Array<{ status: string; veto_type: string | null; picking: string | null }> + >( + `SELECT m.status, get_map_veto_type(m) AS veto_type, + get_map_veto_picking_lineup_id(m) AS picking + FROM matches m WHERE m.id = $1`, + [matchId], + ); + return row; + }; + + const insertPick = ( + matchId: string, + type: string, + lineupId: string, + mapId: string, + side: string | null = null, + ) => + postgres.query( + `INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id, side) + VALUES ($1, $2, $3, $4, $5)`, + [matchId, type, lineupId, mapId, side], + ); + + it("computes the CS rulebook patterns", async () => { + const bo1 = await createVetoMatch(1, 3); + const [{ pattern: p1 }] = await postgres.query< + Array<{ pattern: string[] }> + >("SELECT get_map_veto_pattern(m) AS pattern FROM matches m WHERE id = $1", [ + bo1.id, + ]); + expect(p1).toEqual(["Ban", "Ban", "Decider"]); + + const bo3 = await createVetoMatch(3, 4); + const [{ pattern: p3 }] = await postgres.query< + Array<{ pattern: string[] }> + >("SELECT get_map_veto_pattern(m) AS pattern FROM matches m WHERE id = $1", [ + bo3.id, + ]); + expect(p3).toEqual(["Ban", "Pick", "Side", "Pick", "Side", "Decider"]); + }); + + it("enforces type, turn, side, and pool membership", async () => { + const match = await createVetoMatch(1, 3); + + const state = await vetoState(match.id); + expect(state.status).toBe("Veto"); + expect(state.veto_type).toBe("Ban"); + expect(state.picking).toBe(match.lineup_1_id); + + // Wrong type for the current step. + await expect( + insertPick(match.id, "Pick", match.lineup_1_id, match.mapIds[0]), + ).rejects.toThrow(/Expected pick type of Ban/i); + + // Wrong lineup for the current turn. + await expect( + insertPick(match.id, "Ban", match.lineup_2_id, match.mapIds[0]), + ).rejects.toThrow(/Expected other lineup/i); + + // A Ban must not carry a side. + await expect( + insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0], "CT"), + ).rejects.toThrow(/Cannot Ban and choose side/i); + + // Maps outside the match's pool are not pickable. + const [foreignMap] = await postgres.query>( + "SELECT id FROM maps WHERE type = 'Wingman' LIMIT 1", + ); + await expect( + insertPick(match.id, "Ban", match.lineup_1_id, foreignMap.id), + ).rejects.toThrow(/Map not available/i); + }); + + it("rejects picks while no veto is in progress", async () => { + const { poolId, mapIds } = await fx.mapPool(3); + const match = await fx.match({ mapVeto: true, mapPoolId: poolId }); + + // Still PickingPlayers: no veto type, no picking lineup, and the DB + // itself rejects the pick (previously the NULL step slipped through + // every comparison and only the Hasura permission function stood in + // the way). + const state = await vetoState(match.id); + expect(state.veto_type).toBeNull(); + expect(state.picking).toBeNull(); + + await expect( + insertPick(match.id, "Ban", match.lineup_1_id, mapIds[0]), + ).rejects.toThrow(/No map veto in progress/i); + + const [{ allowed }] = await postgres.query< + Array<{ allowed: boolean | null }> + >( + `SELECT lineup_is_picking_map_veto(ml) AS allowed + FROM match_lineups ml WHERE ml.id = $1`, + [match.lineup_1_id], + ); + // NULL (no active step) — Hasura treats anything but true as denied. + expect(allowed).toBeFalsy(); + }); + + it("runs a BO1 veto: alternating bans, auto-Decider, map materialized, match Live", async () => { + const match = await createVetoMatch(1, 3); + + await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); + expect((await vetoState(match.id)).picking).toBe(match.lineup_2_id); + + await insertPick(match.id, "Ban", match.lineup_2_id, match.mapIds[1]); + + const picks = await postgres.query>( + "SELECT type, map_id FROM match_map_veto_picks WHERE match_id = $1 ORDER BY created_at", + [match.id], + ); + expect(picks.map((p) => p.type)).toEqual(["Ban", "Ban", "Decider"]); + expect(picks[2].map_id).toBe(match.mapIds[2]); + + const maps = await postgres.query< + Array<{ map_id: string; order: number }> + >('SELECT map_id, "order" FROM match_maps WHERE match_id = $1', [match.id]); + expect(maps.length).toBe(1); + expect(maps[0].map_id).toBe(match.mapIds[2]); + + expect((await vetoState(match.id)).status).toBe("Live"); + }); + + it("runs the BO3 Pick/Side steps and assigns the chosen side to the picking lineup", async () => { + const match = await createVetoMatch(3, 4); + + // Step 1: Ban (lineup 1 opens). + await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); + + // Step 2: Pick — follow whoever the SQL says is up. + let state = await vetoState(match.id); + expect(state.veto_type).toBe("Pick"); + const picker = state.picking!; + await insertPick(match.id, "Pick", picker, match.mapIds[1]); + + // A Pick alone creates no map: the opposing side choice completes it. + let maps = await postgres.query>( + "SELECT id FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(maps.length).toBe(0); + + // Step 3: Side — must be the other lineup, and a side is mandatory. + state = await vetoState(match.id); + expect(state.veto_type).toBe("Side"); + const sider = + picker === match.lineup_1_id ? match.lineup_2_id : match.lineup_1_id; + expect(state.picking).toBe(sider); + + await expect( + insertPick(match.id, "Side", sider, match.mapIds[1]), + ).rejects.toThrow(/Must pick a side/i); + + await insertPick(match.id, "Side", sider, match.mapIds[1], "CT"); + + const [map] = await postgres.query< + Array<{ map_id: string; lineup_1_side: string; lineup_2_side: string }> + >( + "SELECT map_id, lineup_1_side, lineup_2_side FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(map.map_id).toBe(match.mapIds[1]); + // The side chooser gets the side they asked for. + if (sider === match.lineup_1_id) { + expect(map.lineup_1_side).toBe("CT"); + expect(map.lineup_2_side).toBe("TERRORIST"); + } else { + expect(map.lineup_2_side).toBe("CT"); + expect(map.lineup_1_side).toBe("TERRORIST"); + } + }); + + it("deleting a veto pick removes the map it created", async () => { + const match = await createVetoMatch(3, 4); + + await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); + const picker = (await vetoState(match.id)).picking!; + await insertPick(match.id, "Pick", picker, match.mapIds[1]); + const sider = + picker === match.lineup_1_id ? match.lineup_2_id : match.lineup_1_id; + await insertPick(match.id, "Side", sider, match.mapIds[1], "CT"); + + await postgres.query( + "DELETE FROM match_map_veto_picks WHERE match_id = $1 AND map_id = $2", + [match.id, match.mapIds[1]], + ); + + const maps = await postgres.query>( + "SELECT id FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(maps.length).toBe(0); + }); + + it("cancelling a match mid-veto wipes its veto picks", async () => { + const match = await createVetoMatch(1, 3); + await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); + + await postgres.query( + "UPDATE matches SET status = 'Canceled' WHERE id = $1", + [match.id], + ); + + const picks = await postgres.query>( + "SELECT id FROM match_map_veto_picks WHERE match_id = $1", + [match.id], + ); + expect(picks.length).toBe(0); + }); +}); diff --git a/test/match-lifecycle.spec.ts b/test/match-lifecycle.spec.ts new file mode 100644 index 00000000..c01f7a17 --- /dev/null +++ b/test/match-lifecycle.spec.ts @@ -0,0 +1,409 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { + Fixtures, + MatchOptionsOverrides, + MatchRow, +} from "./utils/fixtures"; +import { + bootMigratedDb, + runAsUser, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the match lifecycle triggers (tbi_match / tai_match / tbu_matches / +// tau_matches / tbd+tad_matches): lineup provisioning, region resolution, map +// setup, the pending-match guard, status transition side effects (started_at / +// ended_at / cancels_at), veto redirection, server release, and delete cleanup. +describe("match lifecycle (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("MatchLifecycleTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + + // Two regions so tbi_match_options doesn't collapse region_veto, and so + // multi-region veto scenarios are reachable. + await seedRegionWithServer(postgres, "TestA", 27015); + await seedRegionWithServer(postgres, "TestB", 27016); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + // Matches first (options are RESTRICTed by matches), then leftover options + // (tad_match_options prunes custom pools), then players. + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + }); + + const seedPlayer = () => fx.player(); + + // A Custom pool with exactly `size` maps: setup_match_maps only materializes + // match_maps when the pool size equals best_of. + const createPool = async (size: number) => (await fx.mapPool(size)).poolId; + + const createOptions = (over: MatchOptionsOverrides = {}) => + fx.matchOptions(over); + + const createMatch = (optionsId: string) => fx.match(optionsId); + + const getMatch = async (id: string) => { + const [match] = await postgres.query>( + "SELECT * FROM matches WHERE id = $1", + [id], + ); + return match; + }; + + const setStatus = (id: string, status: string) => + postgres.query("UPDATE matches SET status = $1 WHERE id = $2", [ + status, + id, + ]); + + const addPlayer = (lineupId: string, steamId?: string) => + fx.lineupPlayer(lineupId, steamId); + + const asUser = ( + steamId: string, + role: string, + fn: ( + query: (sql: string, params?: Array) => Promise, + ) => Promise, + ) => runAsUser(postgres, steamId, role, fn); + + describe("insert (tbi_match / tai_match)", () => { + it("auto-creates both lineups and links them back to the match", async () => { + const match = await createMatch(await createOptions()); + + expect(match.lineup_1_id).toBeDefined(); + expect(match.lineup_2_id).toBeDefined(); + expect(match.status).toBe("PickingPlayers"); + + const lineups = await postgres.query>( + "SELECT match_id FROM match_lineups WHERE id IN ($1, $2)", + [match.lineup_1_id, match.lineup_2_id], + ); + expect(lineups.map((l) => l.match_id)).toEqual([match.id, match.id]); + }); + + it("auto-selects the region when only one region is viable", async () => { + const match = await createMatch( + await createOptions({ regions: ["TestA"] }), + ); + expect(match.region).toBe("TestA"); + }); + + it("leaves the region open when several regions are viable", async () => { + const match = await createMatch( + await createOptions({ regions: ["TestA", "TestB"] }), + ); + expect(match.region).toBeNull(); + }); + + it("rejects a match when region veto is disabled and no region can be resolved", async () => { + const optionsId = await createOptions({ + regionVeto: false, + regions: ["TestA", "TestB"], + }); + await expect(createMatch(optionsId)).rejects.toThrow( + /Region veto is disabled/i, + ); + }); + + it("materializes match maps when the pool exactly covers best_of", async () => { + const match = await createMatch( + await createOptions({ mapPoolId: await createPool(1) }), + ); + + const maps = await postgres.query< + Array<{ order: number; lineup_1_side: string; lineup_2_side: string }> + >( + 'SELECT "order", lineup_1_side, lineup_2_side FROM match_maps WHERE match_id = $1', + [match.id], + ); + expect(maps.length).toBe(1); + expect(Number(maps[0].order)).toBe(1); + expect(maps[0].lineup_1_side).toBe("CT"); + expect(maps[0].lineup_2_side).toBe("TERRORIST"); + }); + + it("leaves maps to the veto when the pool is larger than best_of", async () => { + const match = await createMatch(await createOptions()); // seeded 7-map pool + const maps = await postgres.query>( + "SELECT id FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(maps.length).toBe(0); + }); + }); + + describe("pending-match guard for regular users", () => { + // Duel keeps lineups tiny: min players is 1 per side. + const createDuelAs = (steamId: string) => + asUser(steamId, "user", async (query) => { + const [pool] = (await query( + "SELECT id FROM map_pools WHERE type = 'Duel' AND seed = true", + )) as Array<{ id: string }>; + const [options] = (await query( + `INSERT INTO match_options (mr, best_of, type, map_pool_id, map_veto, region_veto, regions) + VALUES (12, 1, 'Duel', $1, false, true, '{TestA}') RETURNING id`, + [pool.id], + )) as Array<{ id: string }>; + const [match] = (await query( + "INSERT INTO matches (match_options_id, organizer_steam_id) VALUES ($1, $2) RETURNING *", + [options.id, steamId], + )) as Array; + return match; + }); + + it("auto-joins the creator into lineup 1 as captain", async () => { + const user = await seedPlayer(); + const match = await createDuelAs(user); + + const [player] = await postgres.query< + Array<{ steam_id: string; captain: boolean }> + >( + "SELECT steam_id, captain FROM match_lineup_players WHERE match_lineup_id = $1", + [match.lineup_1_id], + ); + expect(player.steam_id).toBe(user); + expect(player.captain).toBe(true); + }); + + it("blocks creating a second match while one is pending", async () => { + const user = await seedPlayer(); + await createDuelAs(user); + await expect(createDuelAs(user)).rejects.toThrow(/pending matches/i); + }); + + it("a match scheduled more than an hour out does not block, within an hour does", async () => { + const user = await seedPlayer(); + const match = await createDuelAs(user); + await addPlayer(match.lineup_2_id); + + await postgres.query( + `UPDATE matches SET status = 'Scheduled', scheduled_at = now() + interval '2 hours' WHERE id = $1`, + [match.id], + ); + + const second = await createDuelAs(user); + await setStatus(second.id, "Canceled"); + + await postgres.query( + `UPDATE matches SET scheduled_at = now() + interval '30 minutes' WHERE id = $1`, + [match.id], + ); + await expect(createDuelAs(user)).rejects.toThrow(/pending matches/i); + }); + }); + + describe("status transitions (tbu_matches / tau_matches)", () => { + it("refuses to schedule a match without minimum players", async () => { + const match = await createMatch(await createOptions()); // Competitive: 5 per side + await expect( + postgres.query( + `UPDATE matches SET status = 'Scheduled', scheduled_at = now() + interval '2 hours' WHERE id = $1`, + [match.id], + ), + ).rejects.toThrow(/Not enough players to schedule/i); + }); + + it("clears scheduled_at when leaving the Scheduled status", async () => { + const match = await createMatch( + await createOptions({ mapPoolId: await createPool(1), type: "Duel" }), + ); + await addPlayer(match.lineup_1_id); + await addPlayer(match.lineup_2_id); + + await postgres.query( + `UPDATE matches SET status = 'Scheduled', scheduled_at = now() + interval '2 hours' WHERE id = $1`, + [match.id], + ); + expect((await getMatch(match.id)).scheduled_at).not.toBeNull(); + + await setStatus(match.id, "PickingPlayers"); + expect((await getMatch(match.id)).scheduled_at).toBeNull(); + }); + + it("rejects going Live with no maps and map veto disabled", async () => { + const match = await createMatch(await createOptions()); // 7-map pool, best_of 1: no maps yet + await expect(setStatus(match.id, "Live")).rejects.toThrow( + /no maps to play/i, + ); + }); + + it("redirects Live to Veto when map veto still needs to run", async () => { + const match = await createMatch(await createOptions({ mapVeto: true })); + await setStatus(match.id, "Live"); + + const after = await getMatch(match.id); + expect(after.status).toBe("Veto"); + expect(after.cancels_at).not.toBeNull(); + }); + + it("redirects Live to Veto when the region still needs to be picked", async () => { + // Region redirection only applies while the match has no maps yet — a + // pre-picked map list means the match can go straight to a server. + const match = await createMatch( + await createOptions({ regions: ["TestA", "TestB"] }), + ); + expect(match.region).toBeNull(); + + await setStatus(match.id, "Live"); + + const after = await getMatch(match.id); + expect(after.status).toBe("Veto"); + expect(after.region).toBeNull(); + expect(after.cancels_at).not.toBeNull(); + }); + + it("lets a match with maps go Live without a region", async () => { + const match = await createMatch( + await createOptions({ + mapPoolId: await createPool(1), + regions: ["TestA", "TestB"], + }), + ); + expect(match.region).toBeNull(); + + await setStatus(match.id, "Live"); + expect((await getMatch(match.id)).status).toBe("Live"); + }); + + it("entering the check-in window arms auto-cancellation and leaving it resets check-ins", async () => { + const match = await createMatch( + await createOptions({ mapPoolId: await createPool(1), type: "Duel" }), + ); + const p1 = await addPlayer(match.lineup_1_id); + await addPlayer(match.lineup_2_id); + + await setStatus(match.id, "WaitingForCheckIn"); + + const during = await getMatch(match.id); + expect(during.cancels_at).not.toBeNull(); + // Default auto_cancel_duration is 15 minutes from now. + const minutesOut = + (during.cancels_at!.getTime() - Date.now()) / 60_000; + expect(minutesOut).toBeGreaterThan(13); + expect(minutesOut).toBeLessThan(17); + + await postgres.query( + "UPDATE match_lineup_players SET checked_in = true WHERE steam_id = $1", + [p1], + ); + + await setStatus(match.id, "PickingPlayers"); + + const [player] = await postgres.query>( + "SELECT checked_in FROM match_lineup_players WHERE steam_id = $1", + [p1], + ); + expect(player.checked_in).toBe(false); + // cancels_at deliberately survives the exit: only WaitingForServer, + // Live, or a finished status disarm the auto-cancel timer. + }); + + it("runs the full live flow: started_at, finish via winning lineup, server release, cancel refusal", async () => { + const match = await createMatch( + await createOptions({ mapPoolId: await createPool(1) }), + ); + + const [server] = await postgres.query>( + "SELECT id FROM servers WHERE region = 'TestA'", + ); + await postgres.query( + "UPDATE servers SET reserved_by_match_id = $1 WHERE id = $2", + [match.id, server.id], + ); + await postgres.query("UPDATE matches SET server_id = $1 WHERE id = $2", [ + server.id, + match.id, + ]); + + await setStatus(match.id, "Live"); + const live = await getMatch(match.id); + expect(live.status).toBe("Live"); + expect(live.started_at).not.toBeNull(); + expect(live.cancels_at).toBeNull(); + + await postgres.query( + "UPDATE matches SET winning_lineup_id = lineup_1_id WHERE id = $1", + [match.id], + ); + + const finished = await getMatch(match.id); + expect(finished.status).toBe("Finished"); + expect(finished.ended_at).not.toBeNull(); + + const [freed] = await postgres.query< + Array<{ reserved_by_match_id: string | null }> + >("SELECT reserved_by_match_id FROM servers WHERE id = $1", [server.id]); + expect(freed.reserved_by_match_id).toBeNull(); + + await expect(setStatus(match.id, "Canceled")).rejects.toThrow( + /already finished/i, + ); + }); + + it("cancelling stamps cancels_at and clears ended_at", async () => { + const match = await createMatch(await createOptions()); + await setStatus(match.id, "Canceled"); + + const after = await getMatch(match.id); + expect(after.status).toBe("Canceled"); + expect(after.cancels_at).not.toBeNull(); + expect(after.ended_at).toBeNull(); + }); + }); + + describe("delete (tbd_matches / tad_matches)", () => { + it("deleting a match removes its lineups and orphaned options", async () => { + const optionsId = await createOptions({ + mapPoolId: await createPool(1), + }); + const match = await createMatch(optionsId); + + await postgres.query("DELETE FROM matches WHERE id = $1", [match.id]); + + const lineups = await postgres.query>( + "SELECT id FROM match_lineups WHERE id IN ($1, $2)", + [match.lineup_1_id, match.lineup_2_id], + ); + expect(lineups.length).toBe(0); + + const options = await postgres.query>( + "SELECT id FROM match_options WHERE id = $1", + [optionsId], + ); + expect(options.length).toBe(0); + }); + + it("deleting a match releases any server still reserved by it", async () => { + const match = await createMatch( + await createOptions({ mapPoolId: await createPool(1) }), + ); + const [server] = await postgres.query>( + "SELECT id FROM servers WHERE region = 'TestB'", + ); + await postgres.query( + "UPDATE servers SET reserved_by_match_id = $1 WHERE id = $2", + [match.id, server.id], + ); + + await postgres.query("DELETE FROM matches WHERE id = $1", [match.id]); + + const [freed] = await postgres.query< + Array<{ reserved_by_match_id: string | null }> + >("SELECT reserved_by_match_id FROM servers WHERE id = $1", [server.id]); + expect(freed.reserved_by_match_id).toBeNull(); + }); + }); +}); diff --git a/test/match-options.spec.ts b/test/match-options.spec.ts new file mode 100644 index 00000000..0a65cb88 --- /dev/null +++ b/test/match-options.spec.ts @@ -0,0 +1,165 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the match_options triggers: the tbu_match_options edit locks +// (finished matches, invite codes, Live/Veto field freezes) and the +// tau_match_options / tad_match_options map-resync and custom-pool cleanup. +describe("match options locks (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("MatchOptionsTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + // Two regions: with a single one, tbi_match_options force-disables + // region_veto and the freeze assertions would test a no-op. + await seedRegionWithServer(postgres, "TestA", 27015); + await seedRegionWithServer(postgres, "TestB", 27016); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM map_pools WHERE type = 'Custom'"); + }); + + const createPool = async (offset = 0) => { + const { poolId, mapIds } = await fx.mapPool(1, { offset }); + return { poolId, mapId: mapIds[0] }; + }; + + const createMatch = async () => { + const { poolId, mapId } = await createPool(0); + const match = await fx.match({ mapPoolId: poolId }); + return { matchId: match.id, optionsId: match.options_id, poolId, mapId }; + }; + + const setMatchStatus = (matchId: string, status: string) => + postgres.query("UPDATE matches SET status = $1 WHERE id = $2", [ + status, + matchId, + ]); + + const updateOptions = (optionsId: string, set: string) => + postgres.query(`UPDATE match_options SET ${set} WHERE id = $1`, [ + optionsId, + ]); + + it("locks all option edits once the match is finished", async () => { + const { matchId, optionsId } = await createMatch(); + await setMatchStatus(matchId, "Live"); + await postgres.query( + "UPDATE matches SET winning_lineup_id = lineup_1_id WHERE id = $1", + [matchId], + ); + + await expect(updateOptions(optionsId, "knife_round = false")).rejects.toThrow( + /after match is finished/i, + ); + }); + + it("locks the invite code outside of PickingPlayers", async () => { + const { matchId, optionsId } = await createMatch(); + + await updateOptions(optionsId, "invite_code = '123456'"); + + await setMatchStatus(matchId, "Live"); + await expect( + updateOptions(optionsId, "invite_code = '654321'"), + ).rejects.toThrow(/Cannot modify invite code/i); + }); + + it("freezes structural fields during Live", async () => { + const { matchId, optionsId } = await createMatch(); + await setMatchStatus(matchId, "Live"); + + await expect(updateOptions(optionsId, "best_of = 3")).rejects.toThrow( + /Cannot modify best of/i, + ); + await expect(updateOptions(optionsId, "map_veto = true")).rejects.toThrow( + /Cannot modify map veto/i, + ); + await expect( + updateOptions(optionsId, "type = 'Wingman'"), + ).rejects.toThrow(/Cannot modify match type/i); + await expect( + updateOptions(optionsId, "region_veto = false"), + ).rejects.toThrow(/Cannot modify region veto/i); + await expect(updateOptions(optionsId, "mr = 15")).rejects.toThrow( + /Cannot modify mr/i, + ); + + const { poolId: otherPool } = await createPool(1); + await expect( + postgres.query( + "UPDATE match_options SET map_pool_id = $1 WHERE id = $2", + [otherPool, optionsId], + ), + ).rejects.toThrow(/Cannot modify map pool/i); + }); + + it("still allows cosmetic edits during Live", async () => { + const { matchId, optionsId } = await createMatch(); + await setMatchStatus(matchId, "Live"); + + await updateOptions(optionsId, "coaches = true"); + + const [row] = await postgres.query>( + "SELECT coaches FROM match_options WHERE id = $1", + [optionsId], + ); + expect(row.coaches).toBe(true); + }); + + it("swapping the map pool before the match re-syncs its maps", async () => { + const { matchId, optionsId, mapId } = await createMatch(); + + const [before] = await postgres.query>( + "SELECT map_id FROM match_maps WHERE match_id = $1", + [matchId], + ); + expect(before.map_id).toBe(mapId); + + const { poolId: otherPool, mapId: otherMap } = await createPool(1); + await postgres.query( + "UPDATE match_options SET map_pool_id = $1 WHERE id = $2", + [otherPool, optionsId], + ); + + const after = await postgres.query>( + "SELECT map_id FROM match_maps WHERE match_id = $1", + [matchId], + ); + expect(after.length).toBe(1); + expect(after[0].map_id).toBe(otherMap); + }); + + it("deleting the match garbage-collects its options and their custom pool", async () => { + const { matchId, optionsId, poolId } = await createMatch(); + + await postgres.query("DELETE FROM matches WHERE id = $1", [matchId]); + + const options = await postgres.query>( + "SELECT 1 FROM match_options WHERE id = $1", + [optionsId], + ); + expect(options.length).toBe(0); + + const pools = await postgres.query>( + "SELECT 1 FROM map_pools WHERE id = $1", + [poolId], + ); + expect(pools.length).toBe(0); + }); +}); diff --git a/test/match-scoring.spec.ts b/test/match-scoring.spec.ts new file mode 100644 index 00000000..8a84e8aa --- /dev/null +++ b/test/match-scoring.spec.ts @@ -0,0 +1,188 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the round -> map -> match progression SQL: lineup_1/2_score read +// the latest round snapshot, tau_match_maps drives update_match_state, and a +// finished map only finishes the match once a lineup owns the series. +describe("match scoring from rounds (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("MatchScoringTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + }); + + // A Live best-of-N match whose maps are materialized from an exactly-sized + // custom pool (pool == best_of skips both vetoes). + const createLiveMatch = async (bestOf: number) => { + const { poolId } = await fx.mapPool(bestOf); + const match = await fx.match({ bestOf, mapPoolId: poolId }); + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + match.id, + ]); + const maps = await postgres.query>( + 'SELECT id FROM match_maps WHERE match_id = $1 ORDER BY "order"', + [match.id], + ); + return { ...match, mapIds: maps.map((m) => m.id) }; + }; + + const recordScore = (mapId: string, s1: number, s2: number) => + fx.roundScore(mapId, s1, s2); + + const finishMap = (mapId: string) => fx.finishMap(mapId); + + const matchRow = async (id: string) => { + const [row] = await postgres.query< + Array<{ + status: string; + winning_lineup_id: string | null; + ended_at: Date | null; + }> + >("SELECT status, winning_lineup_id, ended_at FROM matches WHERE id = $1", [ + id, + ]); + return row; + }; + + it("finishing a BO1 map finishes the match for the higher-scoring lineup", async () => { + const match = await createLiveMatch(1); + await recordScore(match.mapIds[0], 13, 7); + + await finishMap(match.mapIds[0]); + + const after = await matchRow(match.id); + expect(after.status).toBe("Finished"); + expect(after.winning_lineup_id).toBe(match.lineup_1_id); + expect(after.ended_at).not.toBeNull(); + }); + + it("uses the latest round snapshot as the score", async () => { + const match = await createLiveMatch(1); + // The early snapshot has lineup 1 ahead; the final one flips it. + await recordScore(match.mapIds[0], 7, 13); + + const [scores] = await postgres.query< + Array<{ s1: number; s2: number }> + >( + "SELECT lineup_1_score(mm) AS s1, lineup_2_score(mm) AS s2 FROM match_maps mm WHERE id = $1", + [match.mapIds[0]], + ); + expect(Number(scores.s1)).toBe(7); + expect(Number(scores.s2)).toBe(13); + + await finishMap(match.mapIds[0]); + expect((await matchRow(match.id)).winning_lineup_id).toBe( + match.lineup_2_id, + ); + }); + + it("a tied map decides nothing: the match stays Live", async () => { + const match = await createLiveMatch(1); + await recordScore(match.mapIds[0], 12, 12); + + await finishMap(match.mapIds[0]); + + const after = await matchRow(match.id); + expect(after.status).toBe("Live"); + expect(after.winning_lineup_id).toBeNull(); + }); + + it("a BO3 needs two map wins before the match finishes", async () => { + const match = await createLiveMatch(3); + + await recordScore(match.mapIds[0], 13, 7); + await finishMap(match.mapIds[0]); + expect((await matchRow(match.id)).status).toBe("Live"); + + await recordScore(match.mapIds[1], 5, 13); + await finishMap(match.mapIds[1]); + expect((await matchRow(match.id)).status).toBe("Live"); + + await recordScore(match.mapIds[2], 13, 11); + await finishMap(match.mapIds[2]); + + const after = await matchRow(match.id); + expect(after.status).toBe("Finished"); + expect(after.winning_lineup_id).toBe(match.lineup_1_id); + }); + + it("does not overwrite a forfeited match", async () => { + const match = await createLiveMatch(1); + await recordScore(match.mapIds[0], 13, 7); + + await postgres.query("UPDATE matches SET status = 'Forfeit' WHERE id = $1", [ + match.id, + ]); + await finishMap(match.mapIds[0]); + + const after = await matchRow(match.id); + expect(after.status).toBe("Forfeit"); + expect(after.winning_lineup_id).toBeNull(); + }); + + it("a map going Live stamps started_at and arms the live-match timeout", async () => { + const match = await createLiveMatch(1); + + await postgres.query("UPDATE match_maps SET status = 'Live' WHERE id = $1", [ + match.mapIds[0], + ]); + + const [map] = await postgres.query>( + "SELECT started_at FROM match_maps WHERE id = $1", + [match.mapIds[0]], + ); + expect(map.started_at).not.toBeNull(); + + const [live] = await postgres.query>( + "SELECT cancels_at FROM matches WHERE id = $1", + [match.id], + ); + // Default live_match_timeout is 180 minutes. + expect(live.cancels_at).not.toBeNull(); + const minutesOut = (live.cancels_at!.getTime() - Date.now()) / 60_000; + expect(minutesOut).toBeGreaterThan(170); + expect(minutesOut).toBeLessThan(190); + + // Pausing the map disarms the timeout. + await postgres.query( + "UPDATE match_maps SET status = 'Paused' WHERE id = $1", + [match.mapIds[0]], + ); + const [paused] = await postgres.query>( + "SELECT cancels_at FROM matches WHERE id = $1", + [match.id], + ); + expect(paused.cancels_at).toBeNull(); + }); + + it("finishing the map stamps the map's ended_at", async () => { + const match = await createLiveMatch(1); + await recordScore(match.mapIds[0], 13, 7); + await finishMap(match.mapIds[0]); + + const [map] = await postgres.query>( + "SELECT ended_at FROM match_maps WHERE id = $1", + [match.mapIds[0]], + ); + expect(map.ended_at).not.toBeNull(); + }); +}); diff --git a/test/mocks/kubernetes-client-node.ts b/test/mocks/kubernetes-client-node.ts index 5a050d63..d31ecda1 100644 --- a/test/mocks/kubernetes-client-node.ts +++ b/test/mocks/kubernetes-client-node.ts @@ -5,7 +5,7 @@ // class whose instances answer any method call (returning further stubs), so // runtime usage like `new KubeConfig().makeApiClient(CoreV1Api)` keeps working // without dragging the ESM dependency graph into jest. -const instanceHandler: ProxyHandler> = { +const instanceHandler: ProxyHandler = { get: () => () => new Proxy({}, instanceHandler), }; diff --git a/test/permissions.spec.ts b/test/permissions.spec.ts new file mode 100644 index 00000000..f9957b2d --- /dev/null +++ b/test/permissions.spec.ts @@ -0,0 +1,333 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the Hasura permission functions — the layer that decides what a +// session may do. These run as plain SELECTs with an explicit session JSON, +// exactly how Hasura evaluates them. +describe("permission functions (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("PermissionsTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199960000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM draft_games"); + await postgres.query("DELETE FROM lobbies"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + const session = (steamId: string, role = "user") => + JSON.stringify({ "x-hasura-role": role, "x-hasura-user-id": steamId }); + + const boolFn = async ( + fn: string, + table: string, + rowId: string, + idColumn: string, + sessionJson: string, + ) => { + const [row] = await postgres.query>( + `SELECT ${fn}(t, $2::json) AS allowed FROM ${table} t WHERE ${idColumn} = $1`, + [rowId, sessionJson], + ); + // Hasura treats NULL as denied; collapse for assertions. + return row.allowed === true; + }; + + describe("can_check_in", () => { + const setup = async (checkInSetting = "Players") => { + const match = await fx.match({ type: "Wingman", mr: 8, mapVeto: true }); + await postgres.query( + "UPDATE match_options SET check_in_setting = $2 WHERE id = $1", + [match.options_id, checkInSetting], + ); + const captain = await fx.lineupPlayer(match.lineup_1_id); + const mate = await fx.lineupPlayer(match.lineup_1_id); + await postgres.query( + "UPDATE matches SET status = 'WaitingForCheckIn' WHERE id = $1", + [match.id], + ); + return { match, captain, mate }; + }; + + it("lineup members may check in during the window; outsiders never", async () => { + const { match, captain } = await setup(); + expect( + await boolFn("can_check_in", "matches", match.id, "id", session(captain)), + ).toBe(true); + + const outsider = await fx.player(); + expect( + await boolFn("can_check_in", "matches", match.id, "id", session(outsider)), + ).toBe(false); + }); + + it("outside the check-in window nobody checks in", async () => { + const { match, captain } = await setup(); + await postgres.query( + "UPDATE matches SET status = 'PickingPlayers' WHERE id = $1", + [match.id], + ); + expect( + await boolFn("can_check_in", "matches", match.id, "id", session(captain)), + ).toBe(false); + }); + + it("the Captains setting restricts check-in to lineup captains", async () => { + const { match, captain, mate } = await setup("Captains"); + expect( + await boolFn("can_check_in", "matches", match.id, "id", session(captain)), + ).toBe(true); + expect( + await boolFn("can_check_in", "matches", match.id, "id", session(mate)), + ).toBe(false); + }); + }); + + describe("can_start_match and can_cancel_match", () => { + const duelWithPlayers = async (organizer?: string) => { + const match = await fx.match({ type: "Duel" }); + const p1 = await fx.lineupPlayer(match.lineup_1_id); + const p2 = await fx.lineupPlayer(match.lineup_2_id); + if (organizer) { + await postgres.query( + "UPDATE matches SET organizer_steam_id = $2 WHERE id = $1", + [match.id, organizer], + ); + } + return { match, p1, p2 }; + }; + + it("organizers may start a filled match; empty lineups block everyone", async () => { + const organizer = await fx.player(); + const { match } = await duelWithPlayers(organizer); + expect( + await boolFn( + "can_start_match", + "matches", + match.id, + "id", + session(organizer, "match_organizer"), + ), + ).toBe(true); + + const empty = await fx.match({ type: "Duel" }); + await postgres.query( + "UPDATE matches SET organizer_steam_id = $2 WHERE id = $1", + [empty.id, organizer], + ); + expect( + await boolFn( + "can_start_match", + "matches", + empty.id, + "id", + session(organizer, "match_organizer"), + ), + ).toBe(false); + }); + + it("without an organizer both lineups must be checked in", async () => { + const { match, p1, p2 } = await duelWithPlayers(); + expect( + await boolFn("can_start_match", "matches", match.id, "id", session(p1)), + ).toBe(false); + + await postgres.query( + "UPDATE match_lineup_players SET checked_in = true WHERE steam_id IN ($1, $2)", + [p1, p2], + ); + expect( + await boolFn("can_start_match", "matches", match.id, "id", session(p1)), + ).toBe(true); + }); + + it("only the organizer cancels, and never after the match decided", async () => { + const organizer = await fx.player(); + const { match, p1 } = await duelWithPlayers(organizer); + + expect( + await boolFn( + "can_cancel_match", + "matches", + match.id, + "id", + session(organizer, "match_organizer"), + ), + ).toBe(true); + expect( + await boolFn("can_cancel_match", "matches", match.id, "id", session(p1)), + ).toBe(false); + + await postgres.query( + "UPDATE matches SET winning_lineup_id = lineup_1_id WHERE id = $1", + [match.id], + ); + expect( + await boolFn( + "can_cancel_match", + "matches", + match.id, + "id", + session(organizer, "match_organizer"), + ), + ).toBe(false); + }); + }); + + describe("matchmaking guards", () => { + const playerFn = async (fn: string, steam: string) => { + const [row] = await postgres.query>( + `SELECT ${fn}(p) AS v FROM players p WHERE steam_id = $1`, + [steam], + ); + return row.v; + }; + + it("is_in_another_match tracks live and imminent matches only", async () => { + const match = await fx.match({ type: "Duel" }); + const player = await fx.lineupPlayer(match.lineup_1_id); + await fx.lineupPlayer(match.lineup_2_id); + + // PickingPlayers doesn't tie the player up. + expect(await playerFn("is_in_another_match", player)).toBe(false); + + // A far-out scheduled match doesn't either... + await postgres.query( + `UPDATE matches SET status = 'Scheduled', scheduled_at = now() + interval '3 hours' WHERE id = $1`, + [match.id], + ); + expect(await playerFn("is_in_another_match", player)).toBe(false); + + // ...but one within the hour does. + await postgres.query( + `UPDATE matches SET scheduled_at = now() + interval '30 minutes' WHERE id = $1`, + [match.id], + ); + expect(await playerFn("is_in_another_match", player)).toBe(true); + }); + + it("is_in_lobby and is_in_draft reflect current membership", async () => { + const player = await fx.player(); + expect(await playerFn("is_in_lobby", player)).toBe(false); + expect(await playerFn("is_in_draft", player)).toBe(false); + + const host = await fx.player(); + const [draft] = await postgres.query>( + `INSERT INTO draft_games (host_steam_id, type) VALUES ($1, 'Wingman') RETURNING id`, + [host], + ); + await postgres.query( + `INSERT INTO draft_game_players (draft_game_id, steam_id, status) VALUES ($1, $2, 'Accepted')`, + [draft.id, player], + ); + expect(await playerFn("is_in_draft", player)).toBe(true); + + // Completed drafts release the player. + await postgres.query( + "UPDATE draft_games SET status = 'Canceled' WHERE id = $1", + [draft.id], + ); + expect(await playerFn("is_in_draft", player)).toBe(false); + }); + + it("abandoning matches escalates the matchmaking cooldown", async () => { + const player = await fx.player(); + const cooldown = async () => { + const [row] = await postgres.query>( + `SELECT get_player_matchmaking_cooldown(p, $2::json) AS v + FROM players p WHERE steam_id = $1`, + [player, session(player)], + ); + return row.v; + }; + + expect(await cooldown()).toBeNull(); + + // First abandon: 10 minutes. + await postgres.query( + "INSERT INTO abandoned_matches (steam_id, abandoned_at) VALUES ($1, now())", + [player], + ); + const first = await cooldown(); + expect(first).not.toBeNull(); + expect(first!.getTime() - Date.now()).toBeLessThan(11 * 60_000); + + // Second abandon: escalates to an hour. + await postgres.query( + "INSERT INTO abandoned_matches (steam_id, abandoned_at) VALUES ($1, now() + interval '1 second')", + [player], + ); + const second = await cooldown(); + expect(second!.getTime() - Date.now()).toBeGreaterThan(55 * 60_000); + + // Someone else's session sees nothing. + const stranger = await fx.player(); + const [other] = await postgres.query>( + `SELECT get_player_matchmaking_cooldown(p, $2::json) AS v + FROM players p WHERE steam_id = $1`, + [player, session(stranger)], + ); + expect(other.v).toBeNull(); + }); + }); + + describe("team permissions", () => { + it("owner and roster admins manage roles; members and outsiders don't", async () => { + const team = await fx.team(1); + const [mate] = ( + await postgres.query>( + "SELECT player_steam_id FROM team_roster WHERE team_id = $1 AND player_steam_id != $2", + [team.id, team.owner], + ) + ).map((r) => r.player_steam_id); + + expect( + await boolFn("can_change_team_role", "teams", team.id, "id", session(team.owner)), + ).toBe(true); + expect( + await boolFn("can_change_team_role", "teams", team.id, "id", session(mate)), + ).toBe(false); + + await postgres.query( + "UPDATE team_roster SET role = 'Admin' WHERE team_id = $1 AND player_steam_id = $2", + [team.id, mate], + ); + expect( + await boolFn("can_change_team_role", "teams", team.id, "id", session(mate)), + ).toBe(true); + + const outsider = await fx.player(); + expect( + await boolFn("can_change_team_role", "teams", team.id, "id", session(outsider)), + ).toBe(false); + expect( + await boolFn( + "can_change_team_role", + "teams", + team.id, + "id", + session(outsider, "administrator"), + ), + ).toBe(true); + }); + }); +}); diff --git a/test/player-stats.spec.ts b/test/player-stats.spec.ts new file mode 100644 index 00000000..48e6763c --- /dev/null +++ b/test/player-stats.spec.ts @@ -0,0 +1,281 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures, KillOptions } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the player_kills / player_assists stat-maintenance triggers: +// lifetime aggregates (player_stats, player_kills_by_weapon) and the season +// attribution path (player_season_stats), including the delete/decrement side +// used when a demo is reparsed. +describe("player stats triggers (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("PlayerStatsTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM seasons"); + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM players"); + await postgres.query( + "DELETE FROM settings WHERE name = 'public.seasons_enabled'", + ); + }); + + const seedPlayer = () => fx.player(); + + // A minimal finished match plus one map, enough to attach kill/assist rows. + const seedMatch = (endedAt: string | null = null) => fx.bareMatch(endedAt); + + const insertKill = ( + ctx: { matchId: string; mapId: string }, + attacker: string, + victim: string, + opts: KillOptions = {}, + ) => fx.kill(ctx, attacker, victim, opts); + + const stats = async (steam: string) => { + const [row] = await postgres.query< + Array<{ + kills: string; + deaths: string; + assists: string; + headshots: string; + headshot_percentage: number; + }> + >("SELECT * FROM player_stats WHERE player_steam_id = $1", [steam]); + return row + ? { + kills: Number(row.kills), + deaths: Number(row.deaths), + assists: Number(row.assists), + headshots: Number(row.headshots), + headshotPercentage: row.headshot_percentage, + } + : undefined; + }; + + const weaponKills = async (steam: string, weapon: string) => { + const [row] = await postgres.query>( + 'SELECT kill_count FROM player_kills_by_weapon WHERE player_steam_id = $1 AND "with" = $2', + [steam, weapon], + ); + return row ? Number(row.kill_count) : undefined; + }; + + describe("lifetime aggregates", () => { + it("credits the attacker, the victim, and the weapon on a kill", async () => { + const ctx = await seedMatch(); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { headshot: true }); + + expect(await stats(attacker)).toMatchObject({ + kills: 1, + deaths: 0, + headshots: 1, + // Set on the very first kill too, not just from the second one on. + headshotPercentage: 1, + }); + expect(await stats(victim)).toMatchObject({ kills: 0, deaths: 1 }); + expect(await weaponKills(attacker, "ak47")).toBe(1); + }); + + it("tracks headshot percentage across accumulated kills", async () => { + const ctx = await seedMatch(); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + const victim2 = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { headshot: true }); + await insertKill(ctx, attacker, victim2, { headshot: false }); + + expect(await stats(attacker)).toMatchObject({ + kills: 2, + headshots: 1, + headshotPercentage: 0.5, + }); + }); + + it("deleting a kill decrements stats and prunes zeroed weapon rows", async () => { + const ctx = await seedMatch(); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { + headshot: true, + weapon: "awp", + }); + await postgres.query("DELETE FROM player_kills WHERE match_id = $1", [ + ctx.matchId, + ]); + + expect(await stats(attacker)).toMatchObject({ + kills: 0, + headshots: 0, + headshotPercentage: 0, + }); + expect(await stats(victim)).toMatchObject({ deaths: 0 }); + expect(await weaponKills(attacker, "awp")).toBeUndefined(); + }); + + it("never drives stats negative on delete", async () => { + const ctx = await seedMatch(); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim); + // Simulate an already-zeroed aggregate (e.g. a manual reset) before the + // source row is deleted out from under it. + await postgres.query( + "UPDATE player_stats SET kills = 0, deaths = 0 WHERE player_steam_id IN ($1, $2)", + [attacker, victim], + ); + + await postgres.query("DELETE FROM player_kills WHERE match_id = $1", [ + ctx.matchId, + ]); + + expect(await stats(attacker)).toMatchObject({ kills: 0 }); + expect(await stats(victim)).toMatchObject({ deaths: 0 }); + }); + + it("assists increment on insert and floor at zero on delete", async () => { + const ctx = await seedMatch(); + const assister = await seedPlayer(); + const victim = await seedPlayer(); + + await fx.assist(ctx, assister, victim); + expect(await stats(assister)).toMatchObject({ assists: 1 }); + + await postgres.query("DELETE FROM player_assists WHERE match_id = $1", [ + ctx.matchId, + ]); + expect(await stats(assister)).toMatchObject({ assists: 0 }); + + await postgres.query("DELETE FROM player_assists WHERE match_id = $1", [ + ctx.matchId, + ]); + expect(await stats(assister)).toMatchObject({ assists: 0 }); + }); + }); + + describe("season attribution", () => { + const D = (ymd: string) => new Date(`${ymd}T00:00:00Z`).toISOString(); + + const enableSeasons = () => fx.enableSeasons(); + + const createSeason = (start: string, end: string | null) => + fx.season(start, end); + + const seasonStats = async (steam: string, seasonId: string) => { + const [row] = await postgres.query< + Array<{ kills: string; deaths: string; assists: string }> + >( + "SELECT * FROM player_season_stats WHERE player_steam_id = $1 AND season_id = $2", + [steam, seasonId], + ); + return row + ? { + kills: Number(row.kills), + deaths: Number(row.deaths), + assists: Number(row.assists), + } + : undefined; + }; + + it("attributes kills to the season covering the match end", async () => { + await enableSeasons(); + const seasonId = await createSeason(D("2025-01-01"), D("2025-06-01")); + const ctx = await seedMatch(D("2025-02-15")); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { + headshot: true, + time: D("2025-02-15"), + }); + + expect(await seasonStats(attacker, seasonId)).toMatchObject({ + kills: 1, + }); + expect(await seasonStats(victim, seasonId)).toMatchObject({ deaths: 1 }); + }); + + it("decrements the same season on delete so reparses stay balanced", async () => { + await enableSeasons(); + const seasonId = await createSeason(D("2025-01-01"), D("2025-06-01")); + const ctx = await seedMatch(D("2025-02-15")); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { time: D("2025-02-15") }); + await postgres.query("DELETE FROM player_kills WHERE match_id = $1", [ + ctx.matchId, + ]); + + expect(await seasonStats(attacker, seasonId)).toMatchObject({ + kills: 0, + }); + // Lifetime stats decremented too. + expect(await stats(attacker)).toMatchObject({ kills: 0 }); + }); + + it("records no season stats for a match outside any season", async () => { + await enableSeasons(); + await createSeason(D("2025-01-01"), D("2025-06-01")); + const ctx = await seedMatch(D("2024-06-15")); // before the season + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { time: D("2024-06-15") }); + + const rows = await postgres.query>( + "SELECT kills FROM player_season_stats WHERE player_steam_id = $1", + [attacker], + ); + expect(rows.length).toBe(0); + expect(await stats(attacker)).toMatchObject({ kills: 1 }); + }); + + it("records no season stats when the seasons feature is disabled", async () => { + const seasonId = await createSeason(D("2025-01-01"), D("2025-06-01")); + const ctx = await seedMatch(D("2025-02-15")); + const attacker = await seedPlayer(); + const victim = await seedPlayer(); + + await insertKill(ctx, attacker, victim, { time: D("2025-02-15") }); + + expect(await seasonStats(attacker, seasonId)).toBeUndefined(); + expect(await stats(attacker)).toMatchObject({ kills: 1 }); + }); + + it("attributes assists to the covering season", async () => { + await enableSeasons(); + const seasonId = await createSeason(D("2025-01-01"), null); + const ctx = await seedMatch(D("2025-02-15")); + const assister = await seedPlayer(); + const victim = await seedPlayer(); + + await fx.assist(ctx, assister, victim, D("2025-02-15")); + + expect(await seasonStats(assister, seasonId)).toMatchObject({ + assists: 1, + }); + }); + }); +}); diff --git a/test/pools-players-clips.spec.ts b/test/pools-players-clips.spec.ts new file mode 100644 index 00000000..6cd9bcf0 --- /dev/null +++ b/test/pools-players-clips.spec.ts @@ -0,0 +1,256 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + runAsUser, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Sweeps the remaining small trigger surfaces: map-pool membership sync +// against pending matches (tau__map_pool), the players role/registered-name +// guards (tbau_players), and the match-clip summary counters kept on +// match_maps. +describe("map pools, player guards, and clip counters (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("PoolsPlayersClipsTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199700000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + }); + + describe("map pool sync (tau__map_pool)", () => { + it("swapping a pool's map re-syncs pending matches to the new map", async () => { + const { poolId, mapIds } = await fx.mapPool(1); + const match = await fx.match({ mapPoolId: poolId }); + + const [otherMap] = await postgres.query>( + `SELECT id FROM maps WHERE type = 'Competitive' AND id != $1 ORDER BY name LIMIT 1`, + [mapIds[0]], + ); + await postgres.query( + "UPDATE _map_pool SET map_id = $1 WHERE map_pool_id = $2", + [otherMap.id, poolId], + ); + + const maps = await postgres.query>( + "SELECT map_id FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(maps.length).toBe(1); + expect(maps[0].map_id).toBe(otherMap.id); + }); + + it("refuses to shrink a pool below a pending match's best_of", async () => { + const { poolId, mapIds } = await fx.mapPool(2); + await fx.match({ mapPoolId: poolId, bestOf: 2 }); + + await expect( + postgres.query( + "DELETE FROM _map_pool WHERE map_pool_id = $1 AND map_id = $2", + [poolId, mapIds[0]], + ), + ).rejects.toThrow(/Not enough maps in the pool/i); + }); + + it("the update_map_pools settings hook restores seeded pools from the active roster", async () => { + const seededPool = await fx.seededPool("Competitive"); + const [{ c: before }] = await postgres.query>( + "SELECT count(*) AS c FROM _map_pool WHERE map_pool_id = $1", + [seededPool], + ); + + await postgres.query( + `DELETE FROM _map_pool WHERE map_pool_id = $1 AND map_id = + (SELECT map_id FROM _map_pool WHERE map_pool_id = $1 LIMIT 1)`, + [seededPool], + ); + + // The trigger fires on settings UPDATE. + await postgres.query( + `INSERT INTO settings (name, value) VALUES ('update_map_pools', 'false') + ON CONFLICT (name) DO NOTHING`, + ); + await postgres.query( + "UPDATE settings SET value = 'true' WHERE name = 'update_map_pools'", + ); + + const [{ c: after }] = await postgres.query>( + "SELECT count(*) AS c FROM _map_pool WHERE map_pool_id = $1", + [seededPool], + ); + expect(Number(after)).toBe(Number(before)); + }); + + it("does not disturb a Live match's maps", async () => { + const { poolId, mapIds } = await fx.mapPool(1); + const match = await fx.match({ mapPoolId: poolId }); + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + match.id, + ]); + + const [otherMap] = await postgres.query>( + `SELECT id FROM maps WHERE type = 'Competitive' AND id != $1 ORDER BY name LIMIT 1`, + [mapIds[0]], + ); + await postgres.query( + "UPDATE _map_pool SET map_id = $1 WHERE map_pool_id = $2", + [otherMap.id, poolId], + ); + + const maps = await postgres.query>( + "SELECT map_id FROM match_maps WHERE match_id = $1", + [match.id], + ); + expect(maps[0].map_id).toBe(mapIds[0]); + }); + }); + + describe("player guards (tbau_players)", () => { + it("a registered name cannot be claimed twice", async () => { + const first = await fx.player(); + await postgres.query( + "UPDATE players SET name = 'TakenName', name_registered = true WHERE steam_id = $1", + [first], + ); + + const second = await fx.player(); + await expect( + postgres.query( + "UPDATE players SET name = 'TakenName', name_registered = true WHERE steam_id = $1", + [second], + ), + ).rejects.toThrow(/already registered/i); + + // Unregistered duplicates remain allowed. + await postgres.query("UPDATE players SET name = 'TakenName' WHERE steam_id = $1", [ + second, + ]); + }); + + it("nobody can touch the role of a player at or above their own", async () => { + const moderator = await fx.player(); + await postgres.query( + "UPDATE players SET role = 'moderator' WHERE steam_id = $1", + [moderator], + ); + const admin = await fx.player(); + await postgres.query("UPDATE players SET role = 'administrator' WHERE steam_id = $1", [ + admin, + ]); + + await expect( + runAsUser(postgres, moderator, "moderator", (query) => + query("UPDATE players SET role = 'user' WHERE steam_id = $1", [admin]), + ), + ).rejects.toThrow(/above your own/i); + }); + + it("nobody can promote a player beyond their own role", async () => { + const moderator = await fx.player(); + await postgres.query( + "UPDATE players SET role = 'moderator' WHERE steam_id = $1", + [moderator], + ); + const pleb = await fx.player(); + + await expect( + runAsUser(postgres, moderator, "moderator", (query) => + query("UPDATE players SET role = 'administrator' WHERE steam_id = $1", [pleb]), + ), + ).rejects.toThrow(/higher than yourself/i); + + // Promoting within their ceiling works. + await runAsUser(postgres, moderator, "moderator", (query) => + query("UPDATE players SET role = 'verified_user' WHERE steam_id = $1", [ + pleb, + ]), + ); + const [row] = await postgres.query>( + "SELECT role FROM players WHERE steam_id = $1", + [pleb], + ); + expect(row.role).toBe("verified_user"); + }); + }); + + describe("clip summary counters", () => { + const clipSetup = async () => { + const ctx = await fx.bareMatch(); + const owner = await fx.player(); + return { ...ctx, owner }; + }; + + const summary = async (mapId: string) => { + const [row] = await postgres.query< + Array<{ + clips_count: number; + public_clips_count: number; + latest_clip_at: Date | null; + public_latest_clip_at: Date | null; + }> + >( + `SELECT clips_count, public_clips_count, latest_clip_at, public_latest_clip_at + FROM match_maps WHERE id = $1`, + [mapId], + ); + return row; + }; + + const addClip = async ( + mapId: string, + owner: string, + visibility: "private" | "public", + ) => { + const [row] = await postgres.query>( + `INSERT INTO match_clips (user_steam_id, match_map_id, title, visibility) + VALUES ($1, $2, 'clip', $3) RETURNING id`, + [owner, mapId, visibility], + ); + return row.id; + }; + + it("tracks totals and public counts through insert, publish, and delete", async () => { + const { mapId, owner } = await clipSetup(); + + const privateClip = await addClip(mapId, owner, "private"); + await addClip(mapId, owner, "public"); + + let counters = await summary(mapId); + expect(Number(counters.clips_count)).toBe(2); + expect(Number(counters.public_clips_count)).toBe(1); + expect(counters.latest_clip_at).not.toBeNull(); + expect(counters.public_latest_clip_at).not.toBeNull(); + + // Publishing the private clip bumps the public count. + await postgres.query( + "UPDATE match_clips SET visibility = 'public' WHERE id = $1", + [privateClip], + ); + counters = await summary(mapId); + expect(Number(counters.public_clips_count)).toBe(2); + + // Deleting recomputes from scratch. + await postgres.query("DELETE FROM match_clips WHERE id = $1", [ + privateClip, + ]); + counters = await summary(mapId); + expect(Number(counters.clips_count)).toBe(1); + expect(Number(counters.public_clips_count)).toBe(1); + }); + }); +}); diff --git a/test/region-veto.spec.ts b/test/region-veto.spec.ts new file mode 100644 index 00000000..0dc1313b --- /dev/null +++ b/test/region-veto.spec.ts @@ -0,0 +1,158 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the region veto SQL: turn order (get_region_veto_picking_lineup_id), +// verify_region_veto_pick enforcement (turn, LAN guard, last-region guard), and +// auto_select_region_veto (Decider insertion, region lock-in, going Live). +describe("region veto (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("RegionVetoTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + await seedRegionWithServer(postgres, "TestA", 27015); + await seedRegionWithServer(postgres, "TestB", 27016); + await seedRegionWithServer(postgres, "TestC", 27017); + // A LAN region exists but is never veto-able. + await postgres.query( + `INSERT INTO server_regions (value, description, is_lan) VALUES ('Lan', 'Lan', true) + ON CONFLICT (value) DO NOTHING`, + ); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("UPDATE servers SET enabled = true"); + }); + + // A match in Veto with only the region choice outstanding: the single-map + // custom pool materializes maps at insert, so map veto is off the table. + const createVetoMatch = async ( + regions: Array, + { mapVeto = false } = {}, + ) => { + const { poolId } = await fx.mapPool(1); + const match = await fx.match({ regions, mapVeto, mapPoolId: poolId }); + await postgres.query("UPDATE matches SET status = 'Veto' WHERE id = $1", [ + match.id, + ]); + return match; + }; + + const matchState = async (id: string) => { + const [row] = await postgres.query< + Array<{ status: string; region: string | null; picking: string | null }> + >( + `SELECT m.status, m.region, get_region_veto_picking_lineup_id(m) AS picking + FROM matches m WHERE m.id = $1`, + [id], + ); + return row; + }; + + const ban = (matchId: string, lineupId: string, region: string) => + postgres.query( + `INSERT INTO match_region_veto_picks (match_id, type, match_lineup_id, region) + VALUES ($1, 'Ban', $2, $3)`, + [matchId, lineupId, region], + ); + + it("entering Veto clears the region while several are viable", async () => { + const match = await createVetoMatch(["TestA", "TestB", "TestC"]); + const state = await matchState(match.id); + expect(state.status).toBe("Veto"); + expect(state.region).toBeNull(); + expect(state.picking).toBe(match.lineup_1_id); + }); + + it("rejects a ban out of turn", async () => { + const match = await createVetoMatch(["TestA", "TestB", "TestC"]); + await expect(ban(match.id, match.lineup_2_id, "TestA")).rejects.toThrow( + /Expected other lineup/i, + ); + }); + + it("never allows banning the LAN region", async () => { + const match = await createVetoMatch(["TestA", "TestB", "TestC"]); + await expect(ban(match.id, match.lineup_1_id, "Lan")).rejects.toThrow( + /Cannot ban LAN region/i, + ); + }); + + it("refuses to ban the last available region", async () => { + const match = await createVetoMatch(["TestA", "TestB"]); + // Knock region B's only server offline: A becomes the last viable region. + await postgres.query( + "UPDATE servers SET enabled = false WHERE region = 'TestB'", + ); + + await expect(ban(match.id, match.lineup_1_id, "TestA")).rejects.toThrow( + /last available region/i, + ); + }); + + it("alternates turns and auto-decides the leftover region, going Live", async () => { + const match = await createVetoMatch(["TestA", "TestB", "TestC"]); + + await ban(match.id, match.lineup_1_id, "TestA"); + expect((await matchState(match.id)).picking).toBe(match.lineup_2_id); + + await ban(match.id, match.lineup_2_id, "TestB"); + + const picks = await postgres.query>( + "SELECT type, region FROM match_region_veto_picks WHERE match_id = $1 ORDER BY created_at", + [match.id], + ); + expect(picks.map((p) => [p.type, p.region])).toEqual([ + ["Ban", "TestA"], + ["Ban", "TestB"], + ["Decider", "TestC"], + ]); + + const state = await matchState(match.id); + expect(state.region).toBe("TestC"); + // Map veto disabled and maps already materialized: straight to Live. + expect(state.status).toBe("Live"); + // Region locked in: nobody is prompted to pick again. + expect(state.picking).toBeNull(); + }); + + it("stays in Veto after the region decider when map veto is still pending", async () => { + const match = await createVetoMatch(["TestA", "TestB"], { mapVeto: true }); + + await ban(match.id, match.lineup_1_id, "TestA"); + + const state = await matchState(match.id); + expect(state.region).toBe("TestB"); + expect(state.status).toBe("Veto"); + }); + + it("cancelling a match mid-veto wipes its region picks", async () => { + const match = await createVetoMatch(["TestA", "TestB", "TestC"]); + await ban(match.id, match.lineup_1_id, "TestA"); + + await postgres.query( + "UPDATE matches SET status = 'Canceled' WHERE id = $1", + [match.id], + ); + + const picks = await postgres.query>( + "SELECT id FROM match_region_veto_picks WHERE match_id = $1", + [match.id], + ); + expect(picks.length).toBe(0); + }); +}); diff --git a/test/scrims-and-invites.spec.ts b/test/scrims-and-invites.spec.ts new file mode 100644 index 00000000..d7eb4ffd --- /dev/null +++ b/test/scrims-and-invites.spec.ts @@ -0,0 +1,182 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the team-invite guard, the one-open-scrim-per-pair constraint, +// scrim notification cleanup on terminal statuses, and the match-deletion +// hook that cancels a matched scrim while snapshotting reputation data. +describe("scrim requests and team invites (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("ScrimsInvitesTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199600000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM team_scrim_requests"); + await postgres.query("DELETE FROM notifications"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + describe("team invites", () => { + it("rejects inviting a player who is already on the roster", async () => { + const team = await fx.team(0); + await expect( + postgres.query( + `INSERT INTO team_invites (team_id, steam_id, invited_by_player_steam_id) + VALUES ($1, $2, $2)`, + [team.id, team.owner], + ), + ).rejects.toThrow(/already on team/i); + }); + + it("allows inviting an outsider", async () => { + const team = await fx.team(0); + const outsider = await fx.player(); + await postgres.query( + `INSERT INTO team_invites (team_id, steam_id, invited_by_player_steam_id) + VALUES ($1, $2, $3)`, + [team.id, outsider, team.owner], + ); + const invites = await postgres.query>( + "SELECT 1 FROM team_invites WHERE team_id = $1 AND steam_id = $2", + [team.id, outsider], + ); + expect(invites.length).toBe(1); + }); + }); + + describe("scrim requests", () => { + const createRequest = async ( + fromTeam: { id: string; owner: string }, + toTeam: { id: string; owner: string }, + status = "Pending", + ) => { + const [row] = await postgres.query>( + `INSERT INTO team_scrim_requests + (from_team_id, to_team_id, status, requested_by_steam_id, awaiting_team_id, + proposed_scheduled_at, expires_at) + VALUES ($1, $2, $3, $4, $2, now() + interval '1 day', now() + interval '12 hours') + RETURNING id`, + [fromTeam.id, toTeam.id, status, fromTeam.owner], + ); + return row.id; + }; + + it("allows only one open request per team pair, in either direction", async () => { + const teamA = await fx.team(0); + const teamB = await fx.team(0); + await createRequest(teamA, teamB); + + // Same pair, reversed direction: still blocked while one is open. + await expect(createRequest(teamB, teamA)).rejects.toThrow( + /duplicate key|uq_scrim_req_open/i, + ); + }); + + it("permits a new request once the previous one is resolved", async () => { + const teamA = await fx.team(0); + const teamB = await fx.team(0); + const first = await createRequest(teamA, teamB); + await postgres.query( + "UPDATE team_scrim_requests SET status = 'Declined' WHERE id = $1", + [first], + ); + + await expect(createRequest(teamB, teamA)).resolves.toBeDefined(); + }); + + it("resolving to a terminal status clears the actionable notifications", async () => { + const teamA = await fx.team(0); + const teamB = await fx.team(0); + const request = await createRequest(teamA, teamB); + + const seedNotification = (type: string) => + postgres.query( + `INSERT INTO notifications (title, message, steam_id, role, type, entity_id) + VALUES ($1, $1, $2, 'user', $1, $3)`, + [type, teamB.owner, request], + ); + await seedNotification("ScrimRequestReceived"); + await seedNotification("ScrimMatchScheduled"); + // Outcome notifications survive the cleanup. + await seedNotification("ScrimMatchCanceled"); + + await postgres.query( + "UPDATE team_scrim_requests SET status = 'Declined' WHERE id = $1", + [request], + ); + + const remaining = await postgres.query>( + "SELECT type FROM notifications WHERE entity_id = $1", + [request], + ); + expect(remaining.map((n) => n.type)).toEqual(["ScrimMatchCanceled"]); + }); + + it("deleting a matched scrim's match cancels the request and freezes check-in state", async () => { + // Two Wingman teams; attaching them to the lineups auto-fills the + // lineups from each team's roster (tau_match_lineups). + const teamA = await fx.team(1); + const teamB = await fx.team(1); + const match = await fx.match({ type: "Wingman", mr: 8, mapVeto: true }); + await postgres.query( + "UPDATE match_lineups SET team_id = $1 WHERE id = $2", + [teamA.id, match.lineup_1_id], + ); + await postgres.query( + "UPDATE match_lineups SET team_id = $1 WHERE id = $2", + [teamB.id, match.lineup_2_id], + ); + // Only team A checked in — the classic no-show scenario. + await postgres.query( + `UPDATE match_lineup_players SET checked_in = true + WHERE match_lineup_id = $1 AND steam_id = $2`, + [match.lineup_1_id, teamA.owner], + ); + + const request = await createRequest(teamA, teamB, "Matched"); + await postgres.query( + "UPDATE team_scrim_requests SET match_id = $1 WHERE id = $2", + [match.id, request], + ); + + await postgres.query("DELETE FROM matches WHERE id = $1", [match.id]); + + const [after] = await postgres.query< + Array<{ + status: string; + match_outcome: string | null; + from_team_checked_in: boolean | null; + to_team_checked_in: boolean | null; + responded_at: Date | null; + }> + >( + `SELECT status, match_outcome, from_team_checked_in, to_team_checked_in, responded_at + FROM team_scrim_requests WHERE id = $1`, + [request], + ); + expect(after.status).toBe("Cancelled"); + expect(after.match_outcome).toBe("PickingPlayers"); + expect(after.from_team_checked_in).toBe(true); + expect(after.to_team_checked_in).toBe(false); + expect(after.responded_at).not.toBeNull(); + }); + }); +}); diff --git a/test/seasons.spec.ts b/test/seasons.spec.ts index e193061d..f92d722d 100644 --- a/test/seasons.spec.ts +++ b/test/seasons.spec.ts @@ -1,70 +1,29 @@ -import { Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { - PostgreSqlContainer, - StartedPostgreSqlContainer, -} from "@testcontainers/postgresql"; -import { HasuraService } from "./../src/hasura/hasura.service"; import { PostgresService } from "./../src/postgres/postgres.service"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; // Exercises the season-management DB triggers/constraints: numbering, ending // (auto-create next), start/end edits, overlap prevention, deletion (SET NULL vs // cascade), and the needs_rebuild flag. describe("seasons (SQL-driven)", () => { - const IMAGE = "timescale/timescaledb:latest-pg17"; - - let container: StartedPostgreSqlContainer; + let db: SqlTestDb; let postgres: PostgresService; let seq = 0; beforeAll(async () => { - container = await new PostgreSqlContainer(IMAGE) - .withDatabase("hasura") - .withUsername("hasura") - .withPassword("hasura") - .withCommand([ - "postgres", - "-c", - "shared_preload_libraries=timescaledb,pg_stat_statements", - ]) - .start(); - - const configService = new ConfigService({ - postgres: { - connections: { - default: { - host: container.getHost(), - port: container.getPort(), - user: container.getUsername(), - password: container.getPassword(), - database: container.getDatabase(), - max: 5, - }, - }, - }, - app: { demosDomain: "demos.test", relayDomain: "relay.test" }, - }); - - const logger = new Logger("SeasonsTest"); - postgres = new PostgresService(configService, logger); - - await postgres.query("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"); - - const hasuraService = new HasuraService( - logger, - null as never, - configService, - postgres, - ); + db = await bootMigratedDb("SeasonsTest"); + postgres = db.postgres; - await hasuraService.setup(); + // tbi_match resolves regions on every insert; a fresh install has none, so + // seedMatch would otherwise fail with 'No regions with attached servers'. + await seedRegionWithServer(postgres, "TestA"); }, 600_000); afterAll(async () => { - await ( - postgres as unknown as { pool: { end(): Promise } } - )?.pool?.end(); - await container?.stop(); + await db?.stop(); }); beforeEach(async () => { @@ -94,11 +53,24 @@ describe("seasons (SQL-driven)", () => { return row; }; - const listSeasons = () => - postgres.query>( + // pg returns timestamptz columns as Date objects; normalize to ISO strings so + // they compare cleanly against the D() literals used in assertions. + const listSeasons = async () => { + const rows = await postgres.query< + Array & { + starts_at: Date; + ends_at: Date | null; + }> + >( `SELECT id, number, starts_at, ends_at, needs_rebuild FROM seasons ORDER BY starts_at ASC`, ); + return rows.map((row) => ({ + ...row, + starts_at: row.starts_at.toISOString(), + ends_at: row.ends_at?.toISOString() ?? null, + })); + }; const seedPlayer = async () => { const steam = nextSteam(); @@ -127,9 +99,6 @@ describe("seasons (SQL-driven)", () => { const tagElo = async (seasonId: string | null, endedAt: string) => { const steam = await seedPlayer(); const matchId = await seedMatch(endedAt); - await postgres.query( - `INSERT INTO e_match_types (value) VALUES ('Competitive') ON CONFLICT DO NOTHING`, - ); await postgres.query( `INSERT INTO player_elo (steam_id, match_id, type, "current", change, created_at, season_id) VALUES ($1, $2, 'Competitive', 5000, 0, $3, $4)`, @@ -225,7 +194,7 @@ describe("seasons (SQL-driven)", () => { await postgres.query("DELETE FROM seasons WHERE id = $1", [s1.id]); - const rows = await postgres.query( + const rows = await postgres.query>( `SELECT 1 FROM player_season_stats WHERE season_id = $1`, [s1.id], ); @@ -286,9 +255,9 @@ describe("seasons (SQL-driven)", () => { ]); const [row] = await postgres.query< - Array<{ starts_at: string; needs_rebuild: boolean }> + Array<{ starts_at: Date; needs_rebuild: boolean }> >("SELECT starts_at, needs_rebuild FROM seasons WHERE id = $1", [s1.id]); - expect(row.starts_at).toBe(D("2025-02-01")); + expect(row.starts_at.toISOString()).toBe(D("2025-02-01")); expect(row.needs_rebuild).toBe(true); }); diff --git a/test/servers-and-nodes.spec.ts b/test/servers-and-nodes.spec.ts new file mode 100644 index 00000000..c911e4d8 --- /dev/null +++ b/test/servers-and-nodes.spec.ts @@ -0,0 +1,183 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Exercises the game-server-node / servers SQL: on-demand server population +// across a node's port range, dedicated servers taking over (and releasing) +// node slots, rcon password encryption, and the guards on node servers. +describe("servers and game server nodes (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let nodeSeq = 0; + + beforeAll(async () => { + db = await bootMigratedDb("ServersNodesTest"); + postgres = db.postgres; + await postgres.query( + `INSERT INTO server_regions (value, description) VALUES ('NodeRegion', 'NodeRegion') + ON CONFLICT (value) DO NOTHING`, + ); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM servers"); + await postgres.query("DELETE FROM game_server_nodes"); + }); + + // A node whose port range yields five paired game/tv ports. + const createNode = async () => { + const id = `test-node-${++nodeSeq}`; + await postgres.query( + `INSERT INTO game_server_nodes (id, public_ip, start_port_range, end_port_range, region, status, enabled, label) + VALUES ($1, '203.0.113.1', 27015, 27025, 'NodeRegion', 'Online', true, $1)`, + [id], + ); + return id; + }; + + const nodeServers = (nodeId: string) => + postgres.query< + Array<{ + label: string; + port: number; + tv_port: number; + enabled: boolean; + is_dedicated: boolean; + }> + >( + `SELECT label, port, tv_port, enabled, is_dedicated FROM servers + WHERE game_server_node_id = $1 AND is_dedicated = false ORDER BY port`, + [nodeId], + ); + + it("creating a node populates on-demand servers across its port range", async () => { + const nodeId = await createNode(); + + const servers = await nodeServers(nodeId); + expect(servers.length).toBe(5); + expect(servers.map((s) => [Number(s.port), Number(s.tv_port)])).toEqual([ + [27015, 27016], + [27017, 27018], + [27019, 27020], + [27021, 27022], + [27023, 27024], + ]); + expect(servers.every((s) => s.enabled)).toBe(true); + }); + + it("a dedicated server claims the lowest node slot and releases it on delete", async () => { + const nodeId = await createNode(); + + const [dedicated] = await postgres.query< + Array<{ id: string; port: number; tv_port: number }> + >( + `INSERT INTO servers (host, label, rcon_password, port, tv_port, region, type, is_dedicated, enabled, game_server_node_id) + VALUES ('203.0.113.1', 'dedicated', $1, 28000, 28001, 'NodeRegion', 'Ranked', true, true, $2) + RETURNING id, port, tv_port`, + [Buffer.from("secret"), nodeId], + ); + // The requested port is ignored: the trigger assigns the claimed slot's ports. + expect(Number(dedicated.port)).toBe(27015); + expect(Number(dedicated.tv_port)).toBe(27016); + + let servers = await nodeServers(nodeId); + expect(servers.find((s) => Number(s.port) === 27015)!.enabled).toBe(false); + expect(servers.filter((s) => s.enabled).length).toBe(4); + + await postgres.query("DELETE FROM servers WHERE id = $1", [dedicated.id]); + servers = await nodeServers(nodeId); + expect(servers.every((s) => s.enabled)).toBe(true); + }); + + it("encrypts rcon passwords at rest and re-encrypts on change", async () => { + const [server] = await postgres.query>( + `INSERT INTO servers (host, label, rcon_password, port, region, type, is_dedicated, enabled) + VALUES ('203.0.113.9', 'standalone', $1, 27100, 'NodeRegion', 'Ranked', true, true) + RETURNING id`, + [Buffer.from("first-secret")], + ); + + const decrypt = async () => { + const [row] = await postgres.query>( + `SELECT convert_from(pgp_sym_decrypt_bytea(rcon_password, 'test-app-key'), 'utf8') AS plain + FROM servers WHERE id = $1`, + [server.id], + ); + return row.plain; + }; + + expect(await decrypt()).toBe("first-secret"); + const [{ raw }] = await postgres.query>( + "SELECT rcon_password::text AS raw FROM servers WHERE id = $1", + [server.id], + ); + expect(raw).not.toContain("first-secret"); + + await postgres.query( + "UPDATE servers SET rcon_password = $1 WHERE id = $2", + [Buffer.from("second-secret"), server.id], + ); + expect(await decrypt()).toBe("second-secret"); + }); + + it("guards node servers: no type change, no node removal, no orphan node servers", async () => { + const nodeId = await createNode(); + + await expect( + postgres.query( + `UPDATE servers SET type = 'Casual' WHERE game_server_node_id = $1 AND port = 27015`, + [nodeId], + ), + ).rejects.toThrow(/Cannot change the type of a game node server/i); + + await expect( + postgres.query( + `UPDATE servers SET game_server_node_id = NULL WHERE game_server_node_id = $1 AND port = 27015`, + [nodeId], + ), + ).rejects.toThrow(/Cannot remove from a game server node/i); + + await expect( + postgres.query( + `INSERT INTO servers (host, label, rcon_password, port, region, type, is_dedicated, enabled) + VALUES ('203.0.113.9', 'orphan', $1, 27200, 'NodeRegion', 'Ranked', false, true)`, + [Buffer.from("x")], + ), + ).rejects.toThrow(/without a node assigned/i); + }); + + it("deleting a node removes its on-demand servers", async () => { + const nodeId = await createNode(); + await postgres.query("DELETE FROM game_server_nodes WHERE id = $1", [ + nodeId, + ]); + + const servers = await postgres.query>( + "SELECT 1 FROM servers WHERE game_server_node_id = $1", + [nodeId], + ); + expect(servers.length).toBe(0); + }); + + it("region server counts follow enabled servers on healthy nodes", async () => { + const count = async () => { + const [row] = await postgres.query>( + `SELECT total_region_server_count(sr) AS c FROM server_regions sr WHERE value = 'NodeRegion'`, + ); + return Number(row.c); + }; + + expect(await count()).toBe(0); + const nodeId = await createNode(); + expect(await count()).toBe(5); + + await postgres.query( + "UPDATE game_server_nodes SET enabled = false WHERE id = $1", + [nodeId], + ); + expect(await count()).toBe(0); + }); +}); diff --git a/test/stat-recompute.spec.ts b/test/stat-recompute.spec.ts new file mode 100644 index 00000000..9ded28b1 --- /dev/null +++ b/test/stat-recompute.spec.ts @@ -0,0 +1,276 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises recompute_player_match_map_stats (driven by the match_map_rounds +// trigger): finalized-round gating, per-map kill/death/assist/damage +// aggregation, team-kill exclusion, multi-kill buckets, the bulk-import skip +// switch — and detect_round_clutch's won/saved/lost outcomes. +describe("per-map stat recompute and clutch detection (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("StatRecomputeTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199200000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + }); + + const mapStats = async (mapId: string, steam: string) => { + const [row] = await postgres.query< + Array<{ + kills: number; + hs_kills: number; + deaths: number; + assists: number; + damage: number; + two_kill_rounds: number; + three_kill_rounds: number; + rounds_played: number; + }> + >( + "SELECT * FROM player_match_map_stats WHERE match_map_id = $1 AND steam_id = $2", + [mapId, steam], + ); + return row; + }; + + const T = (minutesAgo: number) => + new Date(Date.now() - minutesAgo * 60_000).toISOString(); + + it("only counts events from finalized rounds, catching up as rounds land", async () => { + const ctx = await fx.bareMatch(); + const [attacker, victim] = await fx.players(2); + + await fx.kill(ctx, attacker, victim, { round: 1, time: T(10) }); + await fx.kill(ctx, attacker, victim, { round: 2, time: T(5) }); + + // No rounds finalized yet: the recompute never ran. + expect(await mapStats(ctx.mapId, attacker)).toBeUndefined(); + + await fx.round(ctx.mapId, 1, { time: T(9) }); + expect(await mapStats(ctx.mapId, attacker)).toMatchObject({ + kills: 1, + rounds_played: 1, + }); + + await fx.round(ctx.mapId, 2, { time: T(4) }); + expect(await mapStats(ctx.mapId, attacker)).toMatchObject({ + kills: 2, + rounds_played: 2, + }); + }); + + it("aggregates kills, headshots, deaths, assists, and damage per map", async () => { + const ctx = await fx.bareMatch(); + const [attacker, assister, victim] = await fx.players(3); + + await fx.kill(ctx, attacker, victim, { + round: 1, + headshot: true, + time: T(10), + }); + await fx.assist(ctx, assister, victim, T(10)); + await fx.damage(ctx, attacker, victim, 73, { round: 1 }); + await fx.round(ctx.mapId, 1, { time: T(9) }); + + expect(await mapStats(ctx.mapId, attacker)).toMatchObject({ + kills: 1, + hs_kills: 1, + deaths: 0, + damage: 73, + }); + expect(await mapStats(ctx.mapId, victim)).toMatchObject({ + kills: 0, + deaths: 1, + }); + expect(await mapStats(ctx.mapId, assister)).toMatchObject({ assists: 1 }); + }); + + it("excludes team kills from the kill count (they still count as deaths)", async () => { + const ctx = await fx.bareMatch(); + const [griefer, teammate] = await fx.players(2); + + await fx.kill(ctx, griefer, teammate, { + round: 1, + attackerTeam: "CT", + victimTeam: "CT", + time: T(10), + }); + await fx.round(ctx.mapId, 1, { time: T(9) }); + + expect(await mapStats(ctx.mapId, griefer)).toMatchObject({ kills: 0 }); + expect(await mapStats(ctx.mapId, teammate)).toMatchObject({ deaths: 1 }); + }); + + it("buckets multi-kill rounds exclusively", async () => { + const ctx = await fx.bareMatch(); + const players = await fx.players(6); + const ace = players[0]; + + // Three kills in round 1, two kills in round 2. + for (let i = 1; i <= 3; i++) { + await fx.kill(ctx, ace, players[i], { round: 1, time: T(20 - i) }); + } + await fx.kill(ctx, ace, players[4], { round: 2, time: T(10) }); + await fx.kill(ctx, ace, players[5], { round: 2, time: T(9) }); + await fx.round(ctx.mapId, 1, { time: T(15) }); + await fx.round(ctx.mapId, 2, { time: T(8) }); + + expect(await mapStats(ctx.mapId, ace)).toMatchObject({ + kills: 5, + three_kill_rounds: 1, + two_kill_rounds: 1, + }); + }); + + it("deleting a round drops its events back out of the stats", async () => { + const ctx = await fx.bareMatch(); + const [attacker, victim] = await fx.players(2); + + await fx.kill(ctx, attacker, victim, { round: 1, time: T(10) }); + await fx.kill(ctx, attacker, victim, { round: 2, time: T(5) }); + await fx.round(ctx.mapId, 1, { time: T(9) }); + await fx.round(ctx.mapId, 2, { time: T(4) }); + expect(await mapStats(ctx.mapId, attacker)).toMatchObject({ kills: 2 }); + + await postgres.query( + "DELETE FROM match_map_rounds WHERE match_map_id = $1 AND round = 2", + [ctx.mapId], + ); + expect(await mapStats(ctx.mapId, attacker)).toMatchObject({ kills: 1 }); + }); + + it("honors the bulk-import switch that skips per-row recomputes", async () => { + const ctx = await fx.bareMatch(); + const [attacker, victim] = await fx.players(2); + await fx.kill(ctx, attacker, victim, { round: 1, time: T(10) }); + + await postgres.transaction(async (client) => { + await client.query( + "SELECT set_config('app.skip_round_recompute', 'on', true)", + ); + await client.query( + `INSERT INTO match_map_rounds + (match_map_id, round, lineup_1_score, lineup_2_score, lineup_1_money, lineup_2_money, + "time", lineup_1_timeouts_available, lineup_2_timeouts_available, + lineup_1_side, lineup_2_side, winning_side) + VALUES ($1, 1, 1, 0, 800, 800, now(), 3, 3, 'CT', 'TERRORIST', 'CT')`, + [ctx.mapId], + ); + }); + // Round landed, but the recompute was suppressed. + expect(await mapStats(ctx.mapId, attacker)).toBeUndefined(); + + // The importer's final full recompute picks everything up. + await postgres.query("SELECT recompute_player_match_map_stats($1)", [ + ctx.mapId, + ]); + expect(await mapStats(ctx.mapId, attacker)).toMatchObject({ kills: 1 }); + }); + + describe("detect_round_clutch", () => { + // A 2v2 with named players so the kill feed can construct 1vX endgames. + const clutchSetup = async () => { + const match = await fx.match({ type: "Wingman", mr: 8, mapVeto: true }); + const [a, b, c, d] = await fx.players(4); + await fx.lineupPlayer(match.lineup_1_id, a); + await fx.lineupPlayer(match.lineup_1_id, b); + await fx.lineupPlayer(match.lineup_2_id, c); + await fx.lineupPlayer(match.lineup_2_id, d); + const [map] = await postgres.query>( + `INSERT INTO match_maps (match_id, map_id, "order") + SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1 RETURNING id`, + [match.id], + ); + return { + ctx: { matchId: match.id, mapId: map.id }, + a, + b, + c, + d, + }; + }; + + const clutch = async (mapId: string, round: number) => { + const [row] = await postgres.query< + Array<{ + clutcher_steam_id: string; + against_count: number; + kills_in_clutch: number; + outcome: string; + }> + >("SELECT * FROM detect_round_clutch($1, $2)", [mapId, round]); + return row; + }; + + it("a 1v2 conversion is a won clutch with its kill count", async () => { + const { ctx, a, b, c, d } = await clutchSetup(); + // b falls first: a is alone against c and d, then closes it out. + await fx.kill(ctx, c, b, { round: 1, time: T(10), attackerTeam: "TERRORIST", victimTeam: "CT" }); + await fx.kill(ctx, a, c, { round: 1, time: T(9), attackerTeam: "CT", victimTeam: "TERRORIST" }); + await fx.kill(ctx, a, d, { round: 1, time: T(8), attackerTeam: "CT", victimTeam: "TERRORIST" }); + await fx.round(ctx.mapId, 1, { winningSide: "CT", time: T(7) }); + + expect(await clutch(ctx.mapId, 1)).toMatchObject({ + clutcher_steam_id: a, + against_count: 2, + kills_in_clutch: 2, + outcome: "won", + }); + }); + + it("dying in the 1v2 is a lost clutch", async () => { + const { ctx, a, b, c } = await clutchSetup(); + await fx.kill(ctx, c, b, { round: 1, time: T(10), attackerTeam: "TERRORIST", victimTeam: "CT" }); + await fx.kill(ctx, c, a, { round: 1, time: T(9), attackerTeam: "TERRORIST", victimTeam: "CT" }); + await fx.round(ctx.mapId, 1, { winningSide: "TERRORIST", time: T(7) }); + + expect(await clutch(ctx.mapId, 1)).toMatchObject({ + clutcher_steam_id: a, + against_count: 2, + outcome: "lost", + }); + }); + + it("surviving to a round win without killing everyone is a saved clutch", async () => { + const { ctx, a, b, c } = await clutchSetup(); + // a ends up 1v2, nobody else dies, but a's side takes the round + // (e.g. defuse or time). + await fx.kill(ctx, c, b, { round: 1, time: T(10), attackerTeam: "TERRORIST", victimTeam: "CT" }); + await fx.round(ctx.mapId, 1, { winningSide: "CT", time: T(7) }); + + expect(await clutch(ctx.mapId, 1)).toMatchObject({ + clutcher_steam_id: a, + against_count: 2, + kills_in_clutch: 0, + outcome: "saved", + }); + }); + + it("reports no clutch for a round without a 1vX situation", async () => { + // In a 2v2 the very first kill creates a 1v2, so the only round that + // never reaches a clutch is one with no kills at all (e.g. a timeout). + const { ctx } = await clutchSetup(); + await fx.round(ctx.mapId, 1, { winningSide: "CT", time: T(7) }); + + expect(await clutch(ctx.mapId, 1)).toBeUndefined(); + }); + }); +}); diff --git a/test/streams-and-ranks.spec.ts b/test/streams-and-ranks.spec.ts new file mode 100644 index 00000000..d27349ce --- /dev/null +++ b/test/streams-and-ranks.spec.ts @@ -0,0 +1,136 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the match-stream priority reordering trigger (taud_match_streams) +// and the premier-rank history rollback trigger (players.premier_rank follows +// the latest remaining observation when history rows are removed). +describe("stream priorities and premier rank history (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("StreamsRanksTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199900000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + }); + + describe("match stream priorities", () => { + const setup = async () => { + const match = await fx.match(); + const ids: Array = []; + for (let priority = 1; priority <= 3; priority++) { + const [row] = await postgres.query>( + `INSERT INTO match_streams (match_id, link, title, priority) + VALUES ($1, $2, $2, $3) RETURNING id`, + [match.id, `https://example.test/stream-${priority}`, priority], + ); + ids.push(row.id); + } + return { matchId: match.id, ids }; + }; + + const order = async (matchId: string) => + ( + await postgres.query>( + "SELECT id, priority FROM match_streams WHERE match_id = $1 ORDER BY priority", + [matchId], + ) + ).map((s) => s.id); + + it("moving a stream up pushes the displaced streams down", async () => { + const { matchId, ids } = await setup(); + await postgres.query( + "UPDATE match_streams SET priority = 1 WHERE id = $1", + [ids[2]], + ); + expect(await order(matchId)).toEqual([ids[2], ids[0], ids[1]]); + }); + + it("moving a stream down pulls the others up", async () => { + const { matchId, ids } = await setup(); + await postgres.query( + "UPDATE match_streams SET priority = 3 WHERE id = $1", + [ids[0]], + ); + expect(await order(matchId)).toEqual([ids[1], ids[2], ids[0]]); + }); + + it("deleting a stream compacts the remaining priorities", async () => { + const { matchId, ids } = await setup(); + await postgres.query("DELETE FROM match_streams WHERE id = $1", [ids[1]]); + + const rows = await postgres.query< + Array<{ id: string; priority: number }> + >( + "SELECT id, priority FROM match_streams WHERE match_id = $1 ORDER BY priority", + [matchId], + ); + expect(rows.map((r) => [r.id, Number(r.priority)])).toEqual([ + [ids[0], 1], + [ids[2], 2], + ]); + }); + }); + + describe("premier rank history", () => { + it("deleting observations rolls players.premier_rank back to the latest remaining one", async () => { + const player = await fx.player(); + + // One observation per match (unique per steam_id + match_id + rank_type). + const insertObservation = async (rank: number, daysAgo: number) => { + const { matchId } = await fx.bareMatch(); + const [row] = await postgres.query>( + `INSERT INTO player_premier_rank_history (steam_id, rank, match_id, observed_at) + VALUES ($1, $2, $3, now() - make_interval(days => $4)) RETURNING id`, + [player, rank, matchId, daysAgo], + ); + return row.id; + }; + + await insertObservation(10_000, 10); + const latest = await insertObservation(15_000, 1); + await postgres.query( + "UPDATE players SET premier_rank = 15000 WHERE steam_id = $1", + [player], + ); + + const premierRank = async () => { + const [row] = await postgres.query< + Array<{ premier_rank: number | null }> + >("SELECT premier_rank FROM players WHERE steam_id = $1", [player]); + return row.premier_rank === null ? null : Number(row.premier_rank); + }; + + // Dropping the newest observation falls back to the older one. + await postgres.query( + "DELETE FROM player_premier_rank_history WHERE id = $1", + [latest], + ); + expect(await premierRank()).toBe(10_000); + + // Dropping the last observation clears the rank entirely. + await postgres.query( + "DELETE FROM player_premier_rank_history WHERE steam_id = $1", + [player], + ); + expect(await premierRank()).toBeNull(); + }); + }); +}); diff --git a/test/team-rosters.spec.ts b/test/team-rosters.spec.ts new file mode 100644 index 00000000..2b91375a --- /dev/null +++ b/test/team-rosters.spec.ts @@ -0,0 +1,345 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + runAsUser, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the team / roster / lineup-membership triggers: owner bootstrap +// and captain rules on teams, invite conversion on team_roster, captain +// election and ban enforcement on match_lineup_players, and the sanction +// trigger that clears the VAC flag. +describe("teams, rosters and lineup membership (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("TeamRostersTest"); + postgres = db.postgres; + fx = new Fixtures(postgres); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + const seedPlayer = () => fx.player(); + + // tbi_team_roster reads current_setting('hasura.user') without a fallback, so + // roster writes must carry a user context. + const asUser = ( + steamId: string, + role: string, + fn: ( + query: (sql: string, params?: Array) => Promise, + ) => Promise, + ) => runAsUser(postgres, steamId, role, fn); + + const createTeam = async (owner: string) => { + const [team] = await postgres.query>( + "INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id", + [fx.nextName("team"), owner], + ); + return team.id; + }; + + const getTeamCaptain = async (teamId: string) => { + const [team] = await postgres.query< + Array<{ captain_steam_id: string | null }> + >("SELECT captain_steam_id FROM teams WHERE id = $1", [teamId]); + return team.captain_steam_id; + }; + + const rosterRow = async (teamId: string, steam: string) => { + const [row] = await postgres.query>( + "SELECT role FROM team_roster WHERE team_id = $1 AND player_steam_id = $2", + [teamId, steam], + ); + return row; + }; + + describe("teams and team_roster", () => { + it("creating a team enrolls the owner as Admin and captain", async () => { + const owner = await seedPlayer(); + const teamId = await createTeam(owner); + + expect((await rosterRow(teamId, owner))?.role).toBe("Admin"); + expect(await getTeamCaptain(teamId)).toBe(owner); + }); + + it("a regular user adding a player creates an invite instead of a roster row", async () => { + const owner = await seedPlayer(); + const invitee = await seedPlayer(); + const teamId = await createTeam(owner); + + await asUser(owner, "user", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id) VALUES ($1, $2)", + [teamId, invitee], + ), + ); + + expect(await rosterRow(teamId, invitee)).toBeUndefined(); + const invites = await postgres.query< + Array<{ invited_by_player_steam_id: string }> + >( + "SELECT invited_by_player_steam_id FROM team_invites WHERE team_id = $1 AND steam_id = $2", + [teamId, invitee], + ); + expect(invites.length).toBe(1); + expect(invites[0].invited_by_player_steam_id).toBe(owner); + }); + + it("an admin adds players to the roster directly as Member", async () => { + const owner = await seedPlayer(); + const member = await seedPlayer(); + const teamId = await createTeam(owner); + + await asUser(owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id) VALUES ($1, $2)", + [teamId, member], + ), + ); + + expect((await rosterRow(teamId, member))?.role).toBe("Member"); + }); + + it("rejects a captain who is not on the roster", async () => { + const owner = await seedPlayer(); + const outsider = await seedPlayer(); + const teamId = await createTeam(owner); + + await expect( + postgres.query("UPDATE teams SET captain_steam_id = $1 WHERE id = $2", [ + outsider, + teamId, + ]), + ).rejects.toThrow(/captain must be a team member/i); + }); + + it("removing the captain from the roster falls back to the owner", async () => { + const owner = await seedPlayer(); + const member = await seedPlayer(); + const teamId = await createTeam(owner); + + await asUser(owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id) VALUES ($1, $2)", + [teamId, member], + ), + ); + await postgres.query( + "UPDATE teams SET captain_steam_id = $1 WHERE id = $2", + [member, teamId], + ); + + await postgres.query( + "DELETE FROM team_roster WHERE team_id = $1 AND player_steam_id = $2", + [teamId, member], + ); + + expect(await getTeamCaptain(teamId)).toBe(owner); + }); + + it("removing the owner-captain from the roster leaves the team captainless", async () => { + const owner = await seedPlayer(); + const teamId = await createTeam(owner); + + await postgres.query( + "DELETE FROM team_roster WHERE team_id = $1 AND player_steam_id = $2", + [teamId, owner], + ); + + expect(await getTeamCaptain(teamId)).toBeNull(); + }); + }); + + describe("match lineup membership", () => { + // Wingman keeps lineups at two slots, enough for captain-handover tests. + const createMatch = () => fx.match({ type: "Wingman", mr: 8, mapVeto: true }); + + const addPlayer = (lineupId: string, steam?: string) => + fx.lineupPlayer(lineupId, steam); + + const lineupPlayers = (lineupId: string) => + postgres.query>( + "SELECT steam_id, captain FROM match_lineup_players WHERE match_lineup_id = $1 ORDER BY steam_id", + [lineupId], + ); + + it("the first player to join a lineup becomes captain", async () => { + const match = await createMatch(); + const first = await addPlayer(match.lineup_1_id); + const second = await addPlayer(match.lineup_1_id); + + const players = await lineupPlayers(match.lineup_1_id); + expect(players.find((p) => p.steam_id === first)?.captain).toBe(true); + expect(players.find((p) => p.steam_id === second)?.captain).toBe(false); + }); + + it("rejects joining both lineups of the same match", async () => { + const match = await createMatch(); + const player = await addPlayer(match.lineup_1_id); + + await expect(addPlayer(match.lineup_2_id, player)).rejects.toThrow( + /already added to match/i, + ); + }); + + it("rejects a lineup beyond the type's capacity", async () => { + const match = await createMatch(); + await addPlayer(match.lineup_1_id); + await addPlayer(match.lineup_1_id); + + await expect(addPlayer(match.lineup_1_id)).rejects.toThrow( + /Max number of players/i, + ); + }); + + it("promoting a player to captain demotes the previous captain", async () => { + const match = await createMatch(); + const first = await addPlayer(match.lineup_1_id); + const second = await addPlayer(match.lineup_1_id); + + await postgres.query( + "UPDATE match_lineup_players SET captain = true WHERE match_lineup_id = $1 AND steam_id = $2", + [match.lineup_1_id, second], + ); + + const players = await lineupPlayers(match.lineup_1_id); + expect(players.find((p) => p.steam_id === first)?.captain).toBe(false); + expect(players.find((p) => p.steam_id === second)?.captain).toBe(true); + }); + + it("deleting the captain elects a replacement", async () => { + const match = await createMatch(); + const first = await addPlayer(match.lineup_1_id); + const second = await addPlayer(match.lineup_1_id); + + await postgres.query( + "DELETE FROM match_lineup_players WHERE match_lineup_id = $1 AND steam_id = $2", + [match.lineup_1_id, first], + ); + + const players = await lineupPlayers(match.lineup_1_id); + expect(players.length).toBe(1); + expect(players[0].steam_id).toBe(second); + expect(players[0].captain).toBe(true); + }); + + it("a captain moved to the other lineup loses captaincy and both lineups re-elect", async () => { + const match = await createMatch(); + const cap = await addPlayer(match.lineup_1_id); + const mate = await addPlayer(match.lineup_1_id); + const opponent = await addPlayer(match.lineup_2_id); + + await postgres.query( + "UPDATE match_lineup_players SET match_lineup_id = $1 WHERE steam_id = $2", + [match.lineup_2_id, cap], + ); + + const lineup1 = await lineupPlayers(match.lineup_1_id); + expect(lineup1.length).toBe(1); + expect(lineup1[0].steam_id).toBe(mate); + expect(lineup1[0].captain).toBe(true); + + const lineup2 = await lineupPlayers(match.lineup_2_id); + expect(lineup2.find((p) => p.steam_id === cap)?.captain).toBe(false); + expect(lineup2.find((p) => p.steam_id === opponent)?.captain).toBe(true); + }); + + it("rejects players with an active ban and admits them once it is lifted or expired", async () => { + const match = await createMatch(); + const admin = await seedPlayer(); + const banned = await seedPlayer(); + + const [sanction] = await postgres.query>( + `INSERT INTO player_sanctions (player_steam_id, type, sanctioned_by_steam_id) + VALUES ($1, 'ban', $2) RETURNING id`, + [banned, admin], + ); + await expect(addPlayer(match.lineup_1_id, banned)).rejects.toThrow( + /Currently Banned/i, + ); + + // Soft-deleting the sanction lifts the ban. + await postgres.query( + "UPDATE player_sanctions SET deleted_at = now() WHERE id = $1", + [sanction.id], + ); + await addPlayer(match.lineup_1_id, banned); + + // An expired ban does not block either. + const expired = await seedPlayer(); + await postgres.query( + `INSERT INTO player_sanctions (player_steam_id, type, sanctioned_by_steam_id, remove_sanction_date) + VALUES ($1, 'ban', $2, now() - interval '1 day')`, + [expired, admin], + ); + await addPlayer(match.lineup_2_id, expired); + }); + }); + + describe("player sanctions (tau_player_sanctions)", () => { + it("soft-deleting an automatic ban clears the VAC flag", async () => { + const player = await seedPlayer(); + await postgres.query( + "UPDATE players SET vac_banned = true WHERE steam_id = $1", + [player], + ); + const [sanction] = await postgres.query>( + `INSERT INTO player_sanctions (player_steam_id, type, sanctioned_by_steam_id) + VALUES ($1, 'ban', NULL) RETURNING id`, + [player], + ); + + await postgres.query( + "UPDATE player_sanctions SET deleted_at = now() WHERE id = $1", + [sanction.id], + ); + + const [row] = await postgres.query>( + "SELECT vac_banned FROM players WHERE steam_id = $1", + [player], + ); + expect(row.vac_banned).toBe(false); + }); + + it("soft-deleting a manual ban leaves the VAC flag alone", async () => { + const admin = await seedPlayer(); + const player = await seedPlayer(); + await postgres.query( + "UPDATE players SET vac_banned = true WHERE steam_id = $1", + [player], + ); + const [sanction] = await postgres.query>( + `INSERT INTO player_sanctions (player_steam_id, type, sanctioned_by_steam_id) + VALUES ($1, 'ban', $2) RETURNING id`, + [player, admin], + ); + + await postgres.query( + "UPDATE player_sanctions SET deleted_at = now() WHERE id = $1", + [sanction.id], + ); + + const [row] = await postgres.query>( + "SELECT vac_banned FROM players WHERE steam_id = $1", + [player], + ); + expect(row.vac_banned).toBe(true); + }); + }); +}); diff --git a/test/tournament-edge-cases.spec.ts b/test/tournament-edge-cases.spec.ts new file mode 100644 index 00000000..fe5a47f6 --- /dev/null +++ b/test/tournament-edge-cases.spec.ts @@ -0,0 +1,284 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Tournament edge cases beyond the happy paths: byes for non-power-of-two +// fields, the third-place match, upset-heavy Swiss runs driven by a seeded +// PRNG, and resetting a quarterfinal after the whole bracket played out. +describe("tournament edge cases (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + beforeAll(async () => { + db = await bootMigratedDb("TournamentEdgeTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199980000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + // Deterministic PRNG so the upset runs are reproducible. + const mulberry32 = (seed: number) => () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + const playAllRounds = async ( + stageId: string, + rounds: number, + pickWinner: () => "lineup_1_id" | "lineup_2_id" = () => "lineup_1_id", + ) => { + for (let round = 1; round <= rounds; round++) { + const open = await postgres.query>( + `SELECT match_id FROM tournament_brackets + WHERE tournament_stage_id = $1 AND round = $2 + AND match_id IS NOT NULL AND finished = false + ORDER BY match_number`, + [stageId, round], + ); + for (const bracket of open) { + await tfx.winMatch(bracket.match_id, pickWinner()); + } + } + }; + + describe("byes", () => { + it("a six-team field gives the top seeds a first-round bye and still completes", async () => { + const t = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }], + 6, + ); + const brackets = await tfx.getBrackets(t.stageIds[0]); + + // Two round-1 matches (the byes were pruned), two semis with one + // pre-placed team each, one final. + const roundOne = brackets.filter((b) => b.round === 1); + expect(roundOne.length).toBe(2); + expect( + roundOne.every( + (b) => b.tournament_team_id_1 !== null && b.tournament_team_id_2 !== null, + ), + ).toBe(true); + + const semis = brackets.filter((b) => b.round === 2); + expect(semis.length).toBe(2); + expect( + semis.every((b) => b.tournament_team_id_1 !== null), + ).toBe(true); + + await playAllRounds(t.stageIds[0], 3); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + expect( + (await tfx.getBrackets(t.stageIds[0])).every((b) => b.finished), + ).toBe(true); + }); + + it("a five-team field resolves three byes and still completes", async () => { + const t = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }], + 5, + ); + await playAllRounds(t.stageIds[0], 3); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }); + }); + + describe("third-place match", () => { + it("routes both semifinal losers into a playable third-place decider", async () => { + const t = await tfx.createTournament([ + { type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 4 }, + ]); + await postgres.query( + `UPDATE tournament_stages SET third_place_match = true WHERE tournament_id = $1`, + [t.id], + ); + await tfx.setStatus(t.id, t.organizer, "RegistrationOpen"); + for (let i = 0; i < 4; i++) { + await tfx.registerTeam(t.id, await fx.team(1)); + } + await tfx.setStatus(t.id, t.organizer, "RegistrationClosed"); + await tfx.setStatus(t.id, t.organizer, "Live"); + const stage = ( + await postgres.query>( + "SELECT id FROM tournament_stages WHERE tournament_id = $1", + [t.id], + ) + )[0].id; + + let brackets = await tfx.getBrackets(stage); + expect(brackets.filter((b) => b.round === 2).length).toBe(2); // final + 3rd place + + const semis = brackets.filter((b) => b.round === 1); + const semiLosers = [ + semis[0].tournament_team_id_2, // lineup_1 wins below + semis[1].tournament_team_id_2, + ]; + await playAllRounds(stage, 1); + + brackets = await tfx.getBrackets(stage); + const thirdPlace = brackets.find( + (b) => b.round === 2 && b.match_number === 2, + )!; + expect( + [thirdPlace.tournament_team_id_1, thirdPlace.tournament_team_id_2].sort(), + ).toEqual([...semiLosers].sort()); + expect(thirdPlace.match_id).not.toBeNull(); + + await playAllRounds(stage, 2); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + + // Bronze goes to the third-place winner, not just a semifinal loser. + const bronzeTeams = await postgres.query< + Array<{ tournament_team_id: string }> + >( + `SELECT DISTINCT tournament_team_id FROM tournament_trophies + WHERE tournament_id = $1 AND placement = 3`, + [t.id], + ); + const finalThird = (await tfx.getBrackets(stage)).find( + (b) => b.round === 2 && b.match_number === 2, + )!; + expect(bronzeTeams.map((b) => b.tournament_team_id)).toEqual([ + finalThird.tournament_team_id_1, + ]); + + // And the trophies leaderboard hands each roster exactly its medal: + // value/secondary/tertiary are the gold/silver/bronze counts. + const finalBracket = (await tfx.getBrackets(stage)).find( + (b) => b.round === 2 && b.match_number === 1, + )!; + const roster = async (teamId: string) => + ( + await postgres.query>( + `SELECT player_steam_id FROM tournament_team_roster + WHERE tournament_team_id = $1`, + [teamId], + ) + ) + .map((r) => r.player_steam_id) + .sort(); + const trophies = await postgres.query< + Array<{ + player_steam_id: string; + value: number; + secondary_value: number; + tertiary_value: number; + }> + >("SELECT * FROM get_leaderboard('trophies', 0)"); + const medalists = (pick: (r: (typeof trophies)[number]) => number) => + trophies + .filter((r) => Number(pick(r)) === 1) + .map((r) => r.player_steam_id) + .sort(); + + expect(medalists((r) => r.value)).toEqual( + await roster(finalBracket.tournament_team_id_1!), + ); + expect(medalists((r) => r.secondary_value)).toEqual( + await roster(finalBracket.tournament_team_id_2!), + ); + expect(medalists((r) => r.tertiary_value)).toEqual( + await roster(finalThird.tournament_team_id_1!), + ); + }); + }); + + describe("Swiss under upsets", () => { + it("a random result sequence still lands on the exact Valve pool distribution", async () => { + const t = await tfx.launch( + [{ type: "Swiss", order: 1, minTeams: 16, maxTeams: 16 }], + 16, + ); + const rng = mulberry32(0x5157ac); + + await playAllRounds(t.stageIds[0], 5, () => + rng() < 0.5 ? "lineup_1_id" : "lineup_2_id", + ); + + const results = await tfx.stageResults(t.stageIds[0]); + const byRecord = new Map(); + for (const row of results) { + const key = `${row.wins}-${row.losses}`; + byRecord.set(key, (byRecord.get(key) ?? 0) + 1); + } + // The pool math guarantees this distribution regardless of who wins. + expect(Object.fromEntries(byRecord)).toEqual({ + "3-0": 2, + "3-1": 3, + "3-2": 3, + "2-3": 3, + "1-3": 3, + "0-3": 2, + }); + expect( + (await tfx.getBrackets(t.stageIds[0])).every((b) => b.finished), + ).toBe(true); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }, 180_000); + }); + + describe("deep reset", () => { + it("resetting a quarterfinal unwinds the whole chain and the bracket replays cleanly", async () => { + const t = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 8, maxTeams: 8 }], + 8, + ); + const stage = t.stageIds[0]; + await playAllRounds(stage, 3); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + + const quarterOne = (await tfx.getBrackets(stage)).find( + (b) => b.round === 1 && b.match_number === 1, + )!; + + const preview = await postgres.query< + Array<{ depth: number; will_delete_match: boolean }> + >("SELECT * FROM preview_tournament_match_reset($1) ORDER BY depth", [ + quarterOne.match_id, + ]); + // QF -> SF -> Final: three levels in the chain. + expect(preview.length).toBe(3); + expect(preview.filter((p) => p.will_delete_match).length).toBe(2); + + const deleted = await postgres.query< + Array<{ deleted_match_id: string }> + >("SELECT * FROM reset_tournament_match($1)", [quarterOne.match_id]); + expect(deleted.length).toBe(2); + + expect(await tfx.tournamentStatus(t.id)).toBe("Live"); + const unwound = await tfx.getBrackets(stage); + expect(unwound.filter((b) => !b.finished).length).toBe(3); + + // Replay: the reset quarterfinal, then the rescheduled semi and final. + await tfx.winMatch(quarterOne.match_id!); + await playAllRounds(stage, 2); + await playAllRounds(stage, 3); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + expect( + (await tfx.getBrackets(stage)).every((b) => b.finished), + ).toBe(true); + }); + }); +}); diff --git a/test/tournament-reset.spec.ts b/test/tournament-reset.spec.ts new file mode 100644 index 00000000..94cbc734 --- /dev/null +++ b/test/tournament-reset.spec.ts @@ -0,0 +1,203 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises reset_tournament_match / preview_tournament_match_reset: rewinding +// a reported result unwinds every downstream bracket (slot clearing, match +// deletion, unfinishing), restores the source match, and rolls a finished +// tournament back to Live with its auto trophies revoked. +describe("tournament match reset (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + beforeAll(async () => { + db = await bootMigratedDb("TournamentResetTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199400000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + const SE4 = [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }]; + + // A finished four-team cup: both semifinals and the final won by lineup 1. + const playedOutCup = async () => { + const t = await tfx.launch(SE4, 4); + await tfx.playRound(t.stageIds[0], 1); + await tfx.playRound(t.stageIds[0], 2); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + return t; + }; + + const resetMatch = ( + matchId: string, + newWinner: string | null = null, + status = "WaitingForCheckIn", + ) => + postgres.query>( + "SELECT * FROM reset_tournament_match($1, $2, $3)", + [matchId, newWinner, status], + ); + + it("previews the downstream chain of a reset", async () => { + const t = await playedOutCup(); + const semi = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.round === 1 && b.match_number === 1, + )!; + + const preview = await postgres.query< + Array<{ depth: number; is_source: boolean; will_delete_match: boolean }> + >("SELECT * FROM preview_tournament_match_reset($1) ORDER BY depth", [ + semi.match_id, + ]); + + expect(preview.length).toBe(2); + expect(preview[0]).toMatchObject({ is_source: true, will_delete_match: false }); + expect(preview[1]).toMatchObject({ is_source: false, will_delete_match: true }); + }); + + it("resetting a semifinal unwinds the final and reopens the source match", async () => { + const t = await playedOutCup(); + let brackets = await tfx.getBrackets(t.stageIds[0]); + const semi1 = brackets.find((b) => b.round === 1 && b.match_number === 1)!; + const semi2 = brackets.find((b) => b.round === 1 && b.match_number === 2)!; + const finalMatchId = brackets.find((b) => b.round === 2)!.match_id; + + const deleted = await resetMatch(semi1.match_id!); + expect(deleted.map((d) => d.deleted_match_id)).toEqual([finalMatchId]); + + brackets = await tfx.getBrackets(t.stageIds[0]); + const final = brackets.find((b) => b.round === 2)!; + // Only the reset feeder's slot is vacated; the other semifinal's winner keeps its seat. + const finalTeams = [final.tournament_team_id_1, final.tournament_team_id_2]; + expect(finalTeams.filter((team) => team === null).length).toBe(1); + expect(finalTeams).toContain(semi2.tournament_team_id_1); + expect(final.match_id).toBeNull(); + expect(final.finished).toBe(false); + + expect( + brackets.find((b) => b.id === semi1.id)!.finished, + ).toBe(false); + + const [source] = await postgres.query< + Array<{ status: string; winning_lineup_id: string | null }> + >("SELECT status, winning_lineup_id FROM matches WHERE id = $1", [ + semi1.match_id, + ]); + expect(source.status).toBe("WaitingForCheckIn"); + expect(source.winning_lineup_id).toBeNull(); + }); + + it("rolls a finished tournament back to Live and revokes its trophies", async () => { + const t = await playedOutCup(); + expect( + Number( + ( + await postgres.query>( + "SELECT count(*) AS c FROM tournament_trophies WHERE tournament_id = $1", + [t.id], + ) + )[0].c, + ), + ).toBeGreaterThan(0); + + const semi = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.round === 1, + )!; + await resetMatch(semi.match_id!); + + expect(await tfx.tournamentStatus(t.id)).toBe("Live"); + const [{ c }] = await postgres.query>( + "SELECT count(*) AS c FROM tournament_trophies WHERE tournament_id = $1", + [t.id], + ); + expect(Number(c)).toBe(0); + }); + + it("resetting with a corrected winner repropagates the bracket", async () => { + const t = await playedOutCup(); + let brackets = await tfx.getBrackets(t.stageIds[0]); + const semi1 = brackets.find((b) => b.round === 1 && b.match_number === 1)!; + // The team wrongly eliminated: semifinal 1's slot-2 team. + const wrongedTeam = semi1.tournament_team_id_2; + + const [lineups] = await postgres.query>( + "SELECT lineup_2_id FROM matches WHERE id = $1", + [semi1.match_id], + ); + await resetMatch(semi1.match_id!, lineups.lineup_2_id); + + const [source] = await postgres.query>( + "SELECT status FROM matches WHERE id = $1", + [semi1.match_id], + ); + expect(source.status).toBe("Finished"); + + brackets = await tfx.getBrackets(t.stageIds[0]); + const final = brackets.find((b) => b.round === 2)!; + expect([ + final.tournament_team_id_1, + final.tournament_team_id_2, + ]).toContain(wrongedTeam); + // Both finalists present again: a fresh final match is scheduled. + expect(final.tournament_team_id_1).not.toBeNull(); + expect(final.tournament_team_id_2).not.toBeNull(); + expect(final.match_id).not.toBeNull(); + + // Finish it again to bring the tournament back home. + await tfx.winMatch(final.match_id!); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }); + + it("refuses to reset live matches, foreign winners, and non-bracket matches", async () => { + const t = await tfx.launch(SE4, 4); + const semi = (await tfx.getBrackets(t.stageIds[0])).find( + (b) => b.round === 1, + )!; + + // A map so the match may go Live at all. + await postgres.query( + `INSERT INTO match_maps (match_id, map_id, "order") + SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1`, + [semi.match_id], + ); + await postgres.query("UPDATE matches SET status = 'Live' WHERE id = $1", [ + semi.match_id, + ]); + await expect(resetMatch(semi.match_id!)).rejects.toThrow( + /cannot reset a live match/i, + ); + + await postgres.query( + "UPDATE matches SET status = 'WaitingForCheckIn' WHERE id = $1", + [semi.match_id], + ); + await expect( + resetMatch(semi.match_id!, "00000000-0000-0000-0000-000000000000"), + ).rejects.toThrow(/new winner must be one of the source match lineups/i); + + const stray = await fx.match({ type: "Wingman", mr: 8 }); + await expect(resetMatch(stray.id)).rejects.toThrow( + /not linked to a tournament bracket/i, + ); + }); +}); diff --git a/test/tournament-stages.spec.ts b/test/tournament-stages.spec.ts new file mode 100644 index 00000000..c02bdd19 --- /dev/null +++ b/test/tournament-stages.spec.ts @@ -0,0 +1,402 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the Swiss and RoundRobin stage SQL: bracket generation shape, +// per-round scheduling, W/L pool routing (Swiss), full playthroughs, stage +// minimum propagation across stages, and multi-stage advancement into an +// elimination stage. +describe("tournament stages: Swiss and RoundRobin (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + beforeAll(async () => { + db = await bootMigratedDb("TournamentStagesTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199300000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + // Matches before tournaments: bracket cascade triggers touch sibling + // brackets mid-delete otherwise. + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + describe("RoundRobin", () => { + const RR4 = [ + { type: "RoundRobin", order: 1, minTeams: 4, maxTeams: 4 }, + ]; + + it("seeds a full schedule with teams pre-assigned and only round 1 scheduled", async () => { + const t = await tfx.launch(RR4, 4); + const brackets = await tfx.getBrackets(t.stageIds[0]); + + // 4 teams: 3 rounds of 2 matches, every pairing known up front. + expect(brackets.map((b) => b.round)).toEqual([1, 1, 2, 2, 3, 3]); + expect( + brackets.every( + (b) => b.tournament_team_id_1 !== null && b.tournament_team_id_2 !== null, + ), + ).toBe(true); + + // Every team meets every other exactly once. + const pairings = brackets.map((b) => + [b.tournament_team_id_1, b.tournament_team_id_2].sort().join("|"), + ); + expect(new Set(pairings).size).toBe(6); + + // Only round 1 has matches created. + expect( + brackets.filter((b) => b.match_id !== null).map((b) => b.round), + ).toEqual([1, 1]); + }); + + it("finishing a round schedules the next one", async () => { + const t = await tfx.launch(RR4, 4); + await tfx.playRound(t.stageIds[0], 1); + + const brackets = await tfx.getBrackets(t.stageIds[0]); + expect( + brackets.filter((b) => b.match_id !== null).map((b) => b.round), + ).toEqual([1, 1, 2, 2]); + expect( + brackets.filter((b) => b.round === 1).every((b) => b.finished), + ).toBe(true); + }); + + it("playing every round finishes the tournament with a complete table", async () => { + const t = await tfx.launch(RR4, 4); + for (let round = 1; round <= 3; round++) { + await tfx.playRound(t.stageIds[0], round); + } + + const results = await tfx.stageResults(t.stageIds[0]); + expect(results.length).toBe(4); + // Everyone played all three of their games. + expect( + results.every((r) => Number(r.wins) + Number(r.losses) === 3), + ).toBe(true); + + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }); + }); + + describe("Swiss (Valve format)", () => { + const SWISS16 = [{ type: "Swiss", order: 1, minTeams: 16, maxTeams: 16 }]; + + it("generates the full pool structure up front and schedules round 1", async () => { + const t = await tfx.launch(SWISS16, 16); + const brackets = await tfx.getBrackets(t.stageIds[0]); + + const perRound = (round: number) => + brackets.filter((b) => b.round === round).length; + expect([1, 2, 3, 4, 5].map(perRound)).toEqual([8, 8, 8, 6, 3]); + + const round1 = brackets.filter((b) => b.round === 1); + expect(round1.every((b) => b.match_id !== null)).toBe(true); + expect( + round1.every( + (b) => b.tournament_team_id_1 !== null && b.tournament_team_id_2 !== null, + ), + ).toBe(true); + }); + + it("routes round-1 winners into the 1-0 pool and losers into the 0-1 pool", async () => { + const t = await tfx.launch(SWISS16, 16); + await tfx.playRound(t.stageIds[0], 1); + + const brackets = await tfx.getBrackets(t.stageIds[0]); + const round2 = brackets.filter((b) => b.round === 2); + + // Pool group encodes wins*100 + losses. + const winnersPool = round2.filter((b) => Number(b.group) === 100); + const losersPool = round2.filter((b) => Number(b.group) === 1); + expect(winnersPool.length).toBe(4); + expect(losersPool.length).toBe(4); + for (const bracket of [...winnersPool, ...losersPool]) { + expect(bracket.tournament_team_id_1).not.toBeNull(); + expect(bracket.tournament_team_id_2).not.toBeNull(); + expect(bracket.match_id).not.toBeNull(); + } + + // Round-1 winners all sit in the winners pool. + const round1Winners = new Set( + brackets + .filter((b) => b.round === 1) + .map((b) => b.tournament_team_id_1), + ); + const winnersPoolTeams = winnersPool.flatMap((b) => [ + b.tournament_team_id_1, + b.tournament_team_id_2, + ]); + expect(winnersPoolTeams.every((team) => round1Winners.has(team))).toBe( + true, + ); + }); + + it("plays five rounds to the exact Valve results distribution and finishes", async () => { + const t = await tfx.launch(SWISS16, 16); + for (let round = 1; round <= 5; round++) { + await tfx.playRound(t.stageIds[0], round); + } + + const results = await tfx.stageResults(t.stageIds[0]); + const distribution = new Map(); + for (const row of results) { + const key = `${row.wins}-${row.losses}`; + distribution.set(key, (distribution.get(key) ?? 0) + 1); + } + expect(Object.fromEntries(distribution)).toEqual({ + "3-0": 2, + "3-1": 3, + "3-2": 3, + "2-3": 3, + "1-3": 3, + "0-3": 2, + }); + + const brackets = await tfx.getBrackets(t.stageIds[0]); + expect(brackets.every((b) => b.finished)).toBe(true); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }, 120_000); + + it("an 8-team Swiss fails with a clean error once a pool goes odd", async () => { + // Valve Swiss pool math needs 16 teams: with 8, completing round 3 + // leaves three 2-1 teams (and three 1-2 teams) with no adjacent pool. + // The pairing SQL now surfaces a real error instead of crashing with + // 'record preferred_pool is not assigned yet'. + const t = await tfx.launch( + [{ type: "Swiss", order: 1, minTeams: 8, maxTeams: 8 }], + 8, + ); + await tfx.playRound(t.stageIds[0], 1); + await tfx.playRound(t.stageIds[0], 2); + + await expect(tfx.playRound(t.stageIds[0], 3)).rejects.toThrow( + /Odd number of teams in pool/i, + ); + }); + }); + + describe("DoubleElimination", () => { + const DE4 = [ + { type: "DoubleElimination", order: 1, minTeams: 4, maxTeams: 4 }, + ]; + + it("creates winners, losers, and grand-final brackets", async () => { + const t = await tfx.launch(DE4, 4); + const brackets = await postgres.query< + Array<{ path: string; round: number; match_number: number }> + >( + `SELECT path, round, match_number FROM tournament_brackets + WHERE tournament_stage_id = $1 ORDER BY path, round, match_number`, + [t.stageIds[0]], + ); + expect( + brackets.map((b) => `${b.path}-r${b.round}m${b.match_number}`), + ).toEqual([ + "LB-r1m1", // WB round-1 losers meet + "LB-r2m1", // LB survivor vs WB final loser + "WB-r1m1", + "WB-r1m2", + "WB-r2m1", // WB final + "WB-r3m1", // grand final + ]); + }); + + it("drops losers into the losers bracket and pairs the grand final", async () => { + const t = await tfx.launch(DE4, 4); + const stage = t.stageIds[0]; + + const roundOne = ( + await postgres.query>( + `SELECT id, match_id FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = 'WB' AND round = 1 + ORDER BY match_number`, + [stage], + ) + ); + // Lineup 1 wins match 1, lineup 2 wins match 2. + await tfx.winMatch(roundOne[0].match_id, "lineup_1_id"); + await tfx.winMatch(roundOne[1].match_id, "lineup_2_id"); + + const lbR1 = ( + await postgres.query< + Array<{ + match_id: string | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }> + >( + `SELECT match_id, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = 'LB' AND round = 1`, + [stage], + ) + )[0]; + expect(lbR1.tournament_team_id_1).not.toBeNull(); + expect(lbR1.tournament_team_id_2).not.toBeNull(); + expect(lbR1.match_id).not.toBeNull(); + + // Losers bracket round 1, then the WB final: its loser drops to the LB final. + await tfx.winMatch(lbR1.match_id!, "lineup_1_id"); + const [wbFinal] = await postgres.query>( + `SELECT match_id FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = 'WB' AND round = 2`, + [stage], + ); + await tfx.winMatch(wbFinal.match_id, "lineup_1_id"); + + const [lbFinal] = await postgres.query< + Array<{ + match_id: string | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }> + >( + `SELECT match_id, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = 'LB' AND round = 2`, + [stage], + ); + expect(lbFinal.tournament_team_id_1).not.toBeNull(); + expect(lbFinal.tournament_team_id_2).not.toBeNull(); + expect(lbFinal.match_id).not.toBeNull(); + await tfx.winMatch(lbFinal.match_id!, "lineup_1_id"); + + // Grand final: WB champion vs LB champion, still Live until it's played. + const [grandFinal] = await postgres.query< + Array<{ + match_id: string | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + }> + >( + `SELECT match_id, tournament_team_id_1, tournament_team_id_2 + FROM tournament_brackets + WHERE tournament_stage_id = $1 AND path = 'WB' AND round = 3`, + [stage], + ); + expect(grandFinal.tournament_team_id_1).not.toBeNull(); + expect(grandFinal.tournament_team_id_2).not.toBeNull(); + expect(grandFinal.match_id).not.toBeNull(); + expect(await tfx.tournamentStatus(t.id)).toBe("Live"); + + await tfx.winMatch(grandFinal.match_id!, "lineup_1_id"); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }); + + it("a team that loses twice is out; every team gets its second chance", async () => { + const t = await tfx.launch(DE4, 4); + const stage = t.stageIds[0]; + + // Sweep every schedulable match until the tournament closes. + for (let sweep = 0; sweep < 6; sweep++) { + const open = await postgres.query>( + `SELECT match_id FROM tournament_brackets + WHERE tournament_stage_id = $1 AND match_id IS NOT NULL AND finished = false + ORDER BY round, match_number`, + [stage], + ); + for (const bracket of open) { + await tfx.winMatch(bracket.match_id); + } + if (open.length === 0) break; + } + + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + // 4-team double elimination always resolves in exactly 6 played brackets... minus + // nothing: WB r1 x2, LB r1, WB final, LB final, grand final. + const finished = await postgres.query>( + `SELECT count(*) AS c FROM tournament_brackets + WHERE tournament_stage_id = $1 AND finished = true`, + [stage], + ); + expect(Number(finished[0].c)).toBe(6); + + // Every team except the champion lost at least once; the two LB round + // participants each played at least three series. + const results = await tfx.stageResults(stage); + expect(results.length).toBe(4); + const totalLosses = results.reduce((sum, row) => sum + Number(row.losses), 0); + // 6 matches, 6 losers-of-a-match. + expect(totalLosses).toBe(6); + }); + }); + + describe("multi-stage advancement", () => { + it("adding a later stage raises earlier stage minimums (halving rule)", async () => { + const t = await tfx.createTournament([ + { type: "RoundRobin", order: 1, minTeams: 4, maxTeams: 8 }, + ]); + await postgres.query( + `INSERT INTO tournament_stages (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, 'SingleElimination', 2, 4, 4)`, + [t.id], + ); + + const [stage1] = await postgres.query>( + `SELECT min_teams FROM tournament_stages WHERE tournament_id = $1 AND "order" = 1`, + [t.id], + ); + expect(Number(stage1.min_teams)).toBe(8); + }); + + it("a RoundRobin stage feeds its top teams into the elimination stage", async () => { + const t = await tfx.launch( + [ + { type: "RoundRobin", order: 1, minTeams: 8, maxTeams: 8 }, + { type: "SingleElimination", order: 2, minTeams: 4, maxTeams: 4 }, + ], + 8, + ); + + // 8-team round robin: 7 rounds of 4 matches. + for (let round = 1; round <= 7; round++) { + await tfx.playRound(t.stageIds[0], round); + } + expect( + (await tfx.getBrackets(t.stageIds[0])).every((b) => b.finished), + ).toBe(true); + expect(await tfx.tournamentStatus(t.id)).toBe("Live"); + + // Stage 2 semifinals seeded with four distinct stage-1 teams, matches up. + const stage2 = await tfx.getBrackets(t.stageIds[1]); + const semis = stage2.filter((b) => b.round === 1); + expect(semis.length).toBe(2); + const seededTeams = semis.flatMap((b) => [ + b.tournament_team_id_1, + b.tournament_team_id_2, + ]); + expect(seededTeams.every((team) => team !== null)).toBe(true); + expect(new Set(seededTeams).size).toBe(4); + expect(semis.every((b) => b.match_id !== null)).toBe(true); + + // The stage-1 leader is among the advancers. + const results = await tfx.stageResults(t.stageIds[0]); + expect(seededTeams).toContain(results[0].tournament_team_id); + + // Play out the elimination stage: semifinals then the final. + await tfx.playRound(t.stageIds[1], 1); + await tfx.playRound(t.stageIds[1], 2); + expect(await tfx.tournamentStatus(t.id)).toBe("Finished"); + }, 120_000); + }); +}); diff --git a/test/tournaments.spec.ts b/test/tournaments.spec.ts new file mode 100644 index 00000000..a021fb67 --- /dev/null +++ b/test/tournaments.spec.ts @@ -0,0 +1,425 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + runAsUser, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the tournament SQL end to end: status-transition guards +// (tbu_tournaments), stage validation, team registration (roster copy + +// eligibility), bracket seeding on registration close, match scheduling, +// winner propagation through the bracket (update_tournament_bracket), the +// min-teams auto-cancel, automatic finish, and trophy calculation. +describe("tournaments (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("TournamentsTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199000000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + // Matches must go before tournaments: deleting a bracket deletes its match, + // whose after-delete trigger updates sibling brackets that are mid-cascade + // ("tuple to be deleted was already modified") if the tournament goes first. + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + const seedPlayer = () => fx.player(); + + // A team whose roster is the owner plus `mates` extra players. Wingman + // tournaments need two per lineup, so one mate makes a team eligible. + const createTeam = (mates = 1) => fx.team(mates); + + const createTournament = async ({ + withStage = true, + start = "1 day", + } = {}) => { + const organizer = await seedPlayer(); + const [options] = await postgres.query>( + `INSERT INTO match_options (mr, best_of, type, map_pool_id, map_veto, region_veto, regions) + SELECT 8, 1, 'Wingman', id, false, true, '{TestA}' + FROM map_pools WHERE type = 'Wingman' AND seed = true RETURNING id`, + ); + const [tournament] = await postgres.query>( + `INSERT INTO tournaments (name, start, organizer_steam_id, match_options_id, status) + VALUES ($1, now() + $2::interval, $3, $4, 'Setup') RETURNING id`, + [fx.nextName("cup"), start, organizer, options.id], + ); + if (withStage) { + await postgres.query( + `INSERT INTO tournament_stages (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, 'SingleElimination', 1, 4, 8)`, + [tournament.id], + ); + } + return { id: tournament.id, organizer }; + }; + + const setStatus = (tournamentId: string, organizer: string, status: string) => + runAsUser(postgres, organizer, "admin", (query) => + query("UPDATE tournaments SET status = $1 WHERE id = $2", [ + status, + tournamentId, + ]), + ); + + const registerTeam = ( + tournamentId: string, + team: { id: string; owner: string }, + ) => + runAsUser(postgres, team.owner, "admin", async (query) => { + const [row] = (await query( + `INSERT INTO tournament_teams (tournament_id, team_id, name) + SELECT $1, id, name FROM teams WHERE id = $2 RETURNING id`, + [tournamentId, team.id], + )) as Array<{ id: string }>; + return row.id; + }); + + const getTournament = async (id: string) => { + const [row] = await postgres.query>( + "SELECT status FROM tournaments WHERE id = $1", + [id], + ); + return row; + }; + + type BracketRow = { + id: string; + round: number; + match_number: number; + match_id: string | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + finished: boolean; + }; + + const getBrackets = (tournamentId: string) => + postgres.query>( + `SELECT tb.id, tb.round, tb.match_number, tb.match_id, + tb.tournament_team_id_1, tb.tournament_team_id_2, tb.finished + FROM tournament_brackets tb + INNER JOIN tournament_stages ts ON ts.id = tb.tournament_stage_id + WHERE ts.tournament_id = $1 + ORDER BY tb.round, tb.match_number`, + [tournamentId], + ); + + const winMatch = (matchId: string, lineup: "lineup_1_id" | "lineup_2_id") => + postgres.query( + `UPDATE matches SET winning_lineup_id = ${lineup} WHERE id = $1`, + [matchId], + ); + + // Registration through bracket seeding with four eligible teams. + const seedFourTeamCup = async () => { + const tournament = await createTournament(); + await setStatus(tournament.id, tournament.organizer, "RegistrationOpen"); + const teams = [] as Array<{ id: string; owner: string }>; + for (let i = 0; i < 4; i++) { + teams.push(await createTeam()); + } + for (const team of teams) { + await registerTeam(tournament.id, team); + } + await setStatus(tournament.id, tournament.organizer, "RegistrationClosed"); + return { tournament, teams }; + }; + + describe("status transition guards", () => { + it("cannot open registration without stages", async () => { + const t = await createTournament({ withStage: false }); + await expect( + setStatus(t.id, t.organizer, "RegistrationOpen"), + ).rejects.toThrow(/Cannot open tournament registration/i); + }); + + it("cannot open registration after the start date has passed", async () => { + const t = await createTournament({ start: "-1 hour" }); + await expect( + setStatus(t.id, t.organizer, "RegistrationOpen"), + ).rejects.toThrow(/Cannot open tournament registration/i); + }); + + it("a non-organizer cannot open registration", async () => { + const t = await createTournament(); + const stranger = await seedPlayer(); + await expect( + runAsUser(postgres, stranger, "user", (query) => + query("UPDATE tournaments SET status = 'RegistrationOpen' WHERE id = $1", [ + t.id, + ]), + ), + ).rejects.toThrow(/Cannot open tournament registration/i); + }); + + it("manually finishing is reserved for admins", async () => { + const t = await createTournament(); + const stranger = await seedPlayer(); + await expect( + runAsUser(postgres, stranger, "user", (query) => + query("UPDATE tournaments SET status = 'Finished' WHERE id = $1", [ + t.id, + ]), + ), + ).rejects.toThrow(/handled automatically/i); + }); + + it("rejects a first stage smaller than four teams per group", async () => { + const t = await createTournament({ withStage: false }); + await expect( + postgres.query( + `INSERT INTO tournament_stages (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, 'SingleElimination', 1, 2, 8)`, + [t.id], + ), + ).rejects.toThrow(/at least 4 teams/i); + }); + }); + + describe("registration and eligibility", () => { + it("registering a team copies its roster and marks it eligible", async () => { + const t = await createTournament(); + await setStatus(t.id, t.organizer, "RegistrationOpen"); + const team = await createTeam(); + + const tournamentTeamId = await registerTeam(t.id, team); + + const roster = await postgres.query>( + "SELECT player_steam_id FROM tournament_team_roster WHERE tournament_team_id = $1", + [tournamentTeamId], + ); + // Wingman: owner + one mate fill the two slots. + expect(roster.length).toBe(2); + expect(roster.map((r) => r.player_steam_id)).toContain(team.owner); + + const [row] = await postgres.query< + Array<{ eligible_at: Date | null; captain_steam_id: string }> + >( + "SELECT eligible_at, captain_steam_id FROM tournament_teams WHERE id = $1", + [tournamentTeamId], + ); + expect(row.eligible_at).not.toBeNull(); + // Captain falls back to the team's own captain (the owner). + expect(row.captain_steam_id).toBe(team.owner); + }); + + it("a team below the minimum roster stays ineligible", async () => { + const t = await createTournament(); + await setStatus(t.id, t.organizer, "RegistrationOpen"); + const team = await createTeam(0); // owner only, Wingman needs 2 + + const tournamentTeamId = await registerTeam(t.id, team); + + const [row] = await postgres.query>( + "SELECT eligible_at FROM tournament_teams WHERE id = $1", + [tournamentTeamId], + ); + expect(row.eligible_at).toBeNull(); + }); + + it("rejects roster additions beyond the lineup capacity", async () => { + const t = await createTournament(); + await setStatus(t.id, t.organizer, "RegistrationOpen"); + const team = await createTeam(); + const tournamentTeamId = await registerTeam(t.id, team); + const extra = await seedPlayer(); + + await expect( + runAsUser(postgres, team.owner, "admin", (query) => + query( + `INSERT INTO tournament_team_roster (tournament_team_id, player_steam_id, tournament_id) + VALUES ($1, $2, $3)`, + [tournamentTeamId, extra, t.id], + ), + ), + ).rejects.toThrow(/too many players/i); + }); + + it("dropping below the minimum revokes eligibility and the seed", async () => { + const t = await createTournament(); + await setStatus(t.id, t.organizer, "RegistrationOpen"); + const team = await createTeam(); + const tournamentTeamId = await registerTeam(t.id, team); + + await runAsUser(postgres, team.owner, "admin", (query) => + query( + `DELETE FROM tournament_team_roster + WHERE tournament_team_id = $1 AND player_steam_id != $2`, + [tournamentTeamId, team.owner], + ), + ); + + const [row] = await postgres.query< + Array<{ eligible_at: Date | null; seed: number | null }> + >("SELECT eligible_at, seed FROM tournament_teams WHERE id = $1", [ + tournamentTeamId, + ]); + expect(row.eligible_at).toBeNull(); + expect(row.seed).toBeNull(); + }); + }); + + describe("bracket seeding and progression", () => { + it("closing registration seeds the single-elimination bracket and schedules round 1", async () => { + const { tournament } = await seedFourTeamCup(); + + const brackets = await getBrackets(tournament.id); + expect(brackets.map((b) => b.round)).toEqual([1, 1, 2]); + + const round1 = brackets.filter((b) => b.round === 1); + for (const bracket of round1) { + expect(bracket.tournament_team_id_1).not.toBeNull(); + expect(bracket.tournament_team_id_2).not.toBeNull(); + expect(bracket.match_id).not.toBeNull(); + } + + const final = brackets.find((b) => b.round === 2)!; + expect(final.tournament_team_id_1).toBeNull(); + expect(final.tournament_team_id_2).toBeNull(); + expect(final.match_id).toBeNull(); + }); + + it("winners propagate into the final, which finishes the tournament and awards trophies", async () => { + const { tournament } = await seedFourTeamCup(); + await setStatus(tournament.id, tournament.organizer, "Live"); + expect((await getTournament(tournament.id)).status).toBe("Live"); + + let brackets = await getBrackets(tournament.id); + const round1 = brackets.filter((b) => b.round === 1); + await winMatch(round1[0].match_id!, "lineup_1_id"); + await winMatch(round1[1].match_id!, "lineup_2_id"); + + brackets = await getBrackets(tournament.id); + const final = brackets.find((b) => b.round === 2)!; + const expectedFinalists = [ + brackets.find((b) => b.round === 1 && b.match_number === 1)! + .tournament_team_id_1, + brackets.find((b) => b.round === 1 && b.match_number === 2)! + .tournament_team_id_2, + ].sort(); + expect( + [final.tournament_team_id_1, final.tournament_team_id_2].sort(), + ).toEqual(expectedFinalists); + expect(final.match_id).not.toBeNull(); + expect( + brackets.filter((b) => b.round === 1).every((b) => b.finished), + ).toBe(true); + + await winMatch(final.match_id!, "lineup_1_id"); + + expect((await getTournament(tournament.id)).status).toBe("Finished"); + + const trophies = await postgres.query< + Array<{ placement: number; tournament_team_id: string; manual: boolean }> + >( + `SELECT placement, tournament_team_id, manual FROM tournament_trophies + WHERE tournament_id = $1 ORDER BY placement`, + [tournament.id], + ); + expect(trophies.length).toBeGreaterThan(0); + expect(trophies.every((t) => t.manual === false)).toBe(true); + // The final's winner holds placement 1. + const champions = trophies.filter((t) => Number(t.placement) === 1); + const [finalAfter] = (await getBrackets(tournament.id)).filter( + (b) => b.round === 2, + ); + expect( + champions.every( + (t) => t.tournament_team_id === finalAfter.tournament_team_id_1, + ), + ).toBe(true); + }); + + it("going Live without enough eligible teams auto-cancels", async () => { + const tournament = await createTournament(); + await setStatus(tournament.id, tournament.organizer, "RegistrationOpen"); + // Three eligible teams plus one ineligible: min_teams is 4. + for (let i = 0; i < 3; i++) { + await registerTeam(tournament.id, await createTeam()); + } + await registerTeam(tournament.id, await createTeam(0)); + await setStatus( + tournament.id, + tournament.organizer, + "RegistrationClosed", + ); + + await setStatus(tournament.id, tournament.organizer, "Live"); + + expect((await getTournament(tournament.id)).status).toBe( + "CancelledMinTeams", + ); + }); + + it("teams cannot leave once the tournament is decided", async () => { + const { tournament } = await seedFourTeamCup(); + await setStatus(tournament.id, tournament.organizer, "Live"); + + let brackets = await getBrackets(tournament.id); + for (const bracket of brackets.filter((b) => b.round === 1)) { + await winMatch(bracket.match_id!, "lineup_1_id"); + } + brackets = await getBrackets(tournament.id); + await winMatch(brackets.find((b) => b.round === 2)!.match_id!, "lineup_1_id"); + expect((await getTournament(tournament.id)).status).toBe("Finished"); + + await expect( + postgres.query( + "DELETE FROM tournament_teams WHERE tournament_id = $1", + [tournament.id], + ), + ).rejects.toThrow(/Cannot leave/i); + }); + + it("disabling trophies wipes auto placements; re-enabling rebuilds them", async () => { + const { tournament } = await seedFourTeamCup(); + await setStatus(tournament.id, tournament.organizer, "Live"); + + let brackets = await getBrackets(tournament.id); + for (const bracket of brackets.filter((b) => b.round === 1)) { + await winMatch(bracket.match_id!, "lineup_1_id"); + } + brackets = await getBrackets(tournament.id); + await winMatch(brackets.find((b) => b.round === 2)!.match_id!, "lineup_1_id"); + + const count = async () => + Number( + ( + await postgres.query>( + "SELECT count(*) AS c FROM tournament_trophies WHERE tournament_id = $1", + [tournament.id], + ) + )[0].c, + ); + expect(await count()).toBeGreaterThan(0); + + await postgres.query( + "UPDATE tournaments SET trophies_enabled = false WHERE id = $1", + [tournament.id], + ); + expect(await count()).toBe(0); + + await postgres.query( + "UPDATE tournaments SET trophies_enabled = true WHERE id = $1", + [tournament.id], + ); + expect(await count()).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/utils/fixtures.ts b/test/utils/fixtures.ts new file mode 100644 index 00000000..c4fe487b --- /dev/null +++ b/test/utils/fixtures.ts @@ -0,0 +1,348 @@ +import { PostgresService } from "../../src/postgres/postgres.service"; +import { runAsUser } from "./sql-test-db"; + +// Typed fixture builders shared by the SQL specs. One Fixtures instance per +// suite: it owns a steam-id sequence so every player it mints is unique within +// the suite, and every builder returns the ids the assertions need. +// +// Builders lean on the real triggers wherever the trigger IS the seeding +// mechanism (teams enroll their owner, matches provision lineups, lobbies +// enroll their creator) so specs exercise production behavior, not a parallel +// insert path. + +export type MatchOptionsOverrides = { + type?: string; + bestOf?: number; + mr?: number; + mapVeto?: boolean; + regionVeto?: boolean; + regions?: Array; + mapPoolId?: string; + substitutes?: number; +}; + +export type MatchRow = { + id: string; + lineup_1_id: string; + lineup_2_id: string; + status: string; + region: string | null; + started_at: Date | null; + ended_at: Date | null; + cancels_at: Date | null; + scheduled_at: Date | null; + winning_lineup_id: string | null; +}; + +export type KillOptions = { + weapon?: string; + headshot?: boolean; + time?: string; + round?: number; + // The demo parser always records both team sides; the map-stat recompute + // filters team kills via attacker_team <> attacked_team. + attackerTeam?: string; + victimTeam?: string; +}; + +export class Fixtures { + private seq = 0; + + constructor( + private readonly postgres: PostgresService, + // Distinct bases keep suites from ever colliding on steam ids, even if + // fixtures are shared across parallel workers hitting one database. + private readonly steamBase = 76561190000000000n, + ) {} + + nextSteam(): string { + return (this.steamBase + BigInt(++this.seq)).toString(); + } + + nextName(prefix: string): string { + return `${prefix}${this.seq}`; + } + + async player(name?: string): Promise { + const steam = this.nextSteam(); + await this.postgres.query( + "INSERT INTO players (steam_id, name) VALUES ($1, $2)", + [steam, name ?? `p${this.seq}`], + ); + return steam; + } + + async players(count: number): Promise> { + const steams: Array = []; + for (let i = 0; i < count; i++) { + steams.push(await this.player()); + } + return steams; + } + + // A Custom pool holding exactly `size` maps, so setup_match_maps + // materializes match_maps when size == best_of and defers to the veto + // otherwise. Maps are pulled deterministically (ordered by name); pass an + // offset to build a second pool with disjoint maps. + async mapPool( + size: number, + { mapType = "Competitive", offset = 0 } = {}, + ): Promise<{ poolId: string; mapIds: Array }> { + const [pool] = await this.postgres.query>( + "INSERT INTO map_pools (type) VALUES ('Custom') RETURNING id", + ); + const maps = await this.postgres.query>( + `INSERT INTO _map_pool (map_pool_id, map_id) + SELECT $1, id FROM maps WHERE type = $2 ORDER BY name OFFSET $3 LIMIT $4 + RETURNING map_id`, + [pool.id, mapType, offset, size], + ); + return { poolId: pool.id, mapIds: maps.map((m) => m.map_id) }; + } + + // The install-seeded pool for a match type (Competitive: 7 maps, Wingman: 6, + // Duel: 6) — larger than any best_of, so maps stay with the veto. + async seededPool(type: string): Promise { + const [pool] = await this.postgres.query>( + "SELECT id FROM map_pools WHERE type = $1 AND seed = true", + [type], + ); + return pool.id; + } + + async matchOptions(over: MatchOptionsOverrides = {}): Promise { + const type = over.type ?? "Competitive"; + const mapPoolId = + over.mapPoolId ?? + (await this.seededPool( + type === "Duel" || type === "Wingman" ? type : "Competitive", + )); + const [row] = await this.postgres.query>( + `INSERT INTO match_options + (mr, best_of, type, map_pool_id, map_veto, region_veto, regions, number_of_substitutes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + [ + over.mr ?? 12, + over.bestOf ?? 1, + type, + mapPoolId, + over.mapVeto ?? false, + over.regionVeto ?? true, + over.regions ?? ["TestA"], + over.substitutes ?? 0, + ], + ); + return row.id; + } + + // A match created through the real tbi/tai triggers: lineups provisioned, + // maps materialized when the pool allows, region auto-selected when single. + async match( + options?: string | MatchOptionsOverrides, + ): Promise { + const optionsId = + typeof options === "string" + ? options + : await this.matchOptions(options ?? {}); + const [match] = await this.postgres.query>( + "INSERT INTO matches (match_options_id) VALUES ($1) RETURNING *", + [optionsId], + ); + return { ...match, options_id: optionsId }; + } + + // A minimal optionless match (source '5stack') plus one map — the shape the + // demo-parser writes for imported/finished games; carries kills and rounds. + async bareMatch( + endedAt: string | null = null, + ): Promise<{ matchId: string; mapId: string }> { + const [l1] = await this.postgres.query>( + "INSERT INTO match_lineups DEFAULT VALUES RETURNING id", + ); + const [l2] = await this.postgres.query>( + "INSERT INTO match_lineups DEFAULT VALUES RETURNING id", + ); + const [match] = await this.postgres.query>( + `INSERT INTO matches (lineup_1_id, lineup_2_id, source, ended_at) + VALUES ($1, $2, '5stack', $3) RETURNING id`, + [l1.id, l2.id, endedAt], + ); + const [map] = await this.postgres.query>( + `INSERT INTO match_maps (match_id, map_id, "order") + SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1 RETURNING id`, + [match.id], + ); + return { matchId: match.id, mapId: map.id }; + } + + async lineupPlayer(lineupId: string, steam?: string): Promise { + const steamId = steam ?? (await this.player()); + await this.postgres.query( + "INSERT INTO match_lineup_players (match_lineup_id, steam_id) VALUES ($1, $2)", + [lineupId, steamId], + ); + return steamId; + } + + // A team whose roster is the owner (enrolled by tai_teams) plus `mates` + // extra players added under an admin session. + async team(mates = 0): Promise<{ id: string; owner: string }> { + const owner = await this.player(); + const [team] = await this.postgres.query>( + "INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id", + [this.nextName("team"), owner], + ); + for (let i = 0; i < mates; i++) { + const mate = await this.player(); + await runAsUser(this.postgres, owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Starter')", + [team.id, mate], + ), + ); + } + return { id: team.id, owner }; + } + + async season(start: string, end: string | null = null): Promise { + const [row] = await this.postgres.query>( + "INSERT INTO seasons (starts_at, ends_at) VALUES ($1, $2) RETURNING id", + [start, end], + ); + return row.id; + } + + async enableSeasons(enabled = true): Promise { + await this.postgres.query( + `INSERT INTO settings (name, value) VALUES ('public.seasons_enabled', $1) + ON CONFLICT (name) DO UPDATE SET value = $1`, + [String(enabled)], + ); + } + + async kill( + ctx: { matchId: string; mapId: string }, + attacker: string, + victim: string, + opts: KillOptions = {}, + ): Promise { + await this.postgres.query( + `INSERT INTO player_kills + (match_id, match_map_id, round, attacker_steam_id, attacker_team, + attacked_steam_id, attacked_team, attacked_location, "with", + hitgroup, "time", headshot) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'site', $8, 'head', $9, $10)`, + [ + ctx.matchId, + ctx.mapId, + opts.round ?? 1, + attacker, + opts.attackerTeam ?? "TERRORIST", + victim, + opts.victimTeam ?? "CT", + opts.weapon ?? "ak47", + opts.time ?? new Date().toISOString(), + opts.headshot ?? false, + ], + ); + } + + async damage( + ctx: { matchId: string; mapId: string }, + attacker: string, + victim: string, + amount: number, + opts: { round?: number; attackerTeam?: string; victimTeam?: string } = {}, + ): Promise { + await this.postgres.query( + `INSERT INTO player_damages + (match_id, match_map_id, round, attacker_steam_id, attacker_team, + attacked_steam_id, attacked_team, attacked_location, "with", + damage, damage_armor, health, armor, hitgroup, "time") + VALUES ($1, $2, $3, $4, $5, $6, $7, 'site', 'ak47', $8, 0, 100, 100, 'chest', now())`, + [ + ctx.matchId, + ctx.mapId, + opts.round ?? 1, + attacker, + opts.attackerTeam ?? "TERRORIST", + victim, + opts.victimTeam ?? "CT", + amount, + ], + ); + } + + async assist( + ctx: { matchId: string; mapId: string }, + assister: string, + victim: string, + time?: string, + ): Promise { + await this.postgres.query( + `INSERT INTO player_assists + (match_id, match_map_id, "time", round, attacker_steam_id, attacker_team, attacked_steam_id, attacked_team) + VALUES ($1, $2, $3, 1, $4, 'TERRORIST', $5, 'CT')`, + [ctx.matchId, ctx.mapId, time ?? new Date().toISOString(), assister, victim], + ); + } + + // Two score snapshots (an early one and the final) so consumers of + // "latest round wins" ordering are genuinely exercised. + async roundScore( + mapId: string, + lineup1Score: number, + lineup2Score: number, + ): Promise { + await this.postgres.query( + `INSERT INTO match_map_rounds + (match_map_id, round, lineup_1_score, lineup_2_score, lineup_1_money, lineup_2_money, + "time", lineup_1_timeouts_available, lineup_2_timeouts_available, + lineup_1_side, lineup_2_side, winning_side) + VALUES + ($1, 1, 1, 0, 800, 800, now() - interval '40 minutes', 3, 3, 'CT', 'TERRORIST', 'CT'), + ($1, 2, $2, $3, 16000, 9000, now(), 3, 3, 'TERRORIST', 'CT', 'TERRORIST')`, + [mapId, lineup1Score, lineup2Score], + ); + } + + // A single finalized round snapshot with full control over sides and the + // winner — the recompute/clutch SQL keys off these fields. + async round( + mapId: string, + roundNumber: number, + opts: { + l1Score?: number; + l2Score?: number; + l1Side?: string; + l2Side?: string; + winningSide?: string; + time?: string; + } = {}, + ): Promise { + await this.postgres.query( + `INSERT INTO match_map_rounds + (match_map_id, round, lineup_1_score, lineup_2_score, lineup_1_money, lineup_2_money, + "time", lineup_1_timeouts_available, lineup_2_timeouts_available, + lineup_1_side, lineup_2_side, winning_side) + VALUES ($1, $2, $3, $4, 800, 800, $5, 3, 3, $6, $7, $8)`, + [ + mapId, + roundNumber, + opts.l1Score ?? 1, + opts.l2Score ?? 0, + opts.time ?? new Date().toISOString(), + opts.l1Side ?? "CT", + opts.l2Side ?? "TERRORIST", + opts.winningSide ?? "CT", + ], + ); + } + + async finishMap(mapId: string): Promise { + await this.postgres.query( + "UPDATE match_maps SET status = 'Finished' WHERE id = $1", + [mapId], + ); + } +} diff --git a/test/utils/sql-test-db.ts b/test/utils/sql-test-db.ts new file mode 100644 index 00000000..5b019e33 --- /dev/null +++ b/test/utils/sql-test-db.ts @@ -0,0 +1,154 @@ +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from "@testcontainers/postgresql"; +import { HasuraService } from "../../src/hasura/hasura.service"; +import { PostgresService } from "../../src/postgres/postgres.service"; + +// Image, extensions, and connection user mirror production +// (5stack-panel/base/timescaledb): create_hypertable migrations need +// TimescaleDB, and setup() calls pg_stat_statements_reset() after migrating, +// which requires pg_stat_statements in shared_preload_libraries. +const IMAGE = "timescale/timescaledb:latest-pg17"; + +export interface SqlTestDb { + container: StartedPostgreSqlContainer; + postgres: PostgresService; + hasura: HasuraService; + stop(): Promise; +} + +// Boots a throwaway Postgres and drives the real HasuraService.setup() through +// the full migration -> enums -> functions -> views -> triggers pipeline, so +// trigger/function behavior under test matches a fresh install exactly. +export async function bootMigratedDb(loggerName: string): Promise { + const container = await new PostgreSqlContainer(IMAGE) + .withDatabase("hasura") + .withUsername("hasura") + .withPassword("hasura") + .withCommand([ + "postgres", + "-c", + "shared_preload_libraries=timescaledb,pg_stat_statements", + // The servers trigger encrypts rcon passwords with pgp_sym_encrypt_bytea + // keyed by this GUC; prod provisions it on the database, tests set it at + // server start so seeding servers works. + "-c", + "fivestack.app_key=test-app-key", + ]) + .start(); + + const configService = new ConfigService({ + postgres: { + connections: { + default: { + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getPassword(), + database: container.getDatabase(), + max: 5, + }, + }, + }, + app: { demosDomain: "demos.test", relayDomain: "relay.test" }, + }); + + const logger = new Logger(loggerName); + const postgres = new PostgresService(configService, logger); + + // The prod image provisions the timescaledb extension outside the migrations; + // do the same so create_hypertable migrations resolve. + await postgres.query("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"); + + const hasuraService = new HasuraService( + logger, + // CacheService is unused by setup(); the GraphQL/cache paths are not exercised. + null as never, + configService, + postgres, + ); + + await hasuraService.setup(); + + return { + container, + postgres, + hasura: hasuraService, + stop: async () => { + // Drain the pool before the container goes away, otherwise pg emits an + // idle-client error when the socket is torn out from under it. + await ( + postgres as unknown as { pool: { end(): Promise } } + )?.pool?.end(); + await container?.stop(); + }, + }; +} + +// Runs fn inside a transaction that carries Hasura session variables, the way +// requests arrive through Hasura. current_setting('hasura.user') is read by +// many triggers, so the config must share the transaction's connection. +export async function runAsUser( + postgres: PostgresService, + steamId: string, + role: string, + fn: ( + query: (sql: string, params?: Array) => Promise, + ) => Promise, +): Promise { + const pool = ( + postgres as unknown as { + pool: { + connect(): Promise<{ + query(sql: string, params?: unknown[]): Promise<{ rows: unknown }>; + release(): void; + }>; + }; + } + ).pool; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT set_config('hasura.user', $1, true)", [ + JSON.stringify({ "x-hasura-role": role, "x-hasura-user-id": steamId }), + ]); + const result = await fn((sql, params) => + client.query(sql, params as unknown[]).then((r) => r.rows), + ); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + // A transaction-local set_config leaves the session default as '' (known + // but empty) rather than unset, and ''::jsonb fails for every later + // trigger on this pooled connection. Restore a parseable no-user default. + await client.query("SELECT set_config('hasura.user', '{}', false)"); + client.release(); + } +} + +// A fresh install has no server_regions and no servers, but tbi_match's call to +// sanitize_match_options_regions() raises unless at least one region has an +// enabled server attached. Seed one (or more) so matches can be created at all. +export async function seedRegionWithServer( + postgres: PostgresService, + region: string, + port = 27015, +): Promise { + await postgres.query( + `INSERT INTO server_regions (value, description) + VALUES ($1, $1) ON CONFLICT (value) DO NOTHING`, + [region], + ); + await postgres.query( + `INSERT INTO servers (host, label, rcon_password, port, region, type, is_dedicated, enabled) + VALUES ('127.0.0.1', $1, $2, $3, $1, 'Ranked', true, true)`, + [region, Buffer.from("password"), port], + ); +} diff --git a/test/utils/tournament-fixtures.ts b/test/utils/tournament-fixtures.ts new file mode 100644 index 00000000..4cfe8996 --- /dev/null +++ b/test/utils/tournament-fixtures.ts @@ -0,0 +1,164 @@ +import { PostgresService } from "../../src/postgres/postgres.service"; +import { Fixtures } from "./fixtures"; +import { runAsUser } from "./sql-test-db"; + +// Tournament-flow builders shared by the tournament specs. All state changes +// run under the organizer's admin session, matching how Hasura delivers them. + +export type StageSpec = { + type: string; + order: number; + minTeams: number; + maxTeams: number; +}; + +export type BracketRow = { + id: string; + round: number; + match_number: number; + group: number; + match_id: string | null; + tournament_team_id_1: string | null; + tournament_team_id_2: string | null; + finished: boolean; +}; + +export class TournamentFixtures { + constructor( + private readonly postgres: PostgresService, + private readonly fx: Fixtures, + ) {} + + async createTournament(stages: Array): Promise<{ + id: string; + organizer: string; + stageIds: Array; + }> { + const organizer = await this.fx.player(); + const [options] = await this.postgres.query>( + `INSERT INTO match_options (mr, best_of, type, map_pool_id, map_veto, region_veto, regions) + SELECT 8, 1, 'Wingman', id, false, true, '{TestA}' + FROM map_pools WHERE type = 'Wingman' AND seed = true RETURNING id`, + ); + const [tournament] = await this.postgres.query>( + `INSERT INTO tournaments (name, start, organizer_steam_id, match_options_id, status) + VALUES ($1, now() + interval '1 day', $2, $3, 'Setup') RETURNING id`, + [this.fx.nextName("cup"), organizer, options.id], + ); + const stageIds: Array = []; + for (const stage of stages) { + const [row] = await this.postgres.query>( + `INSERT INTO tournament_stages (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + [tournament.id, stage.type, stage.order, stage.minTeams, stage.maxTeams], + ); + stageIds.push(row.id); + } + return { id: tournament.id, organizer, stageIds }; + } + + setStatus( + tournamentId: string, + organizer: string, + status: string, + ): Promise { + return runAsUser(this.postgres, organizer, "admin", (query) => + query("UPDATE tournaments SET status = $1 WHERE id = $2", [ + status, + tournamentId, + ]), + ); + } + + registerTeam( + tournamentId: string, + team: { id: string; owner: string }, + ): Promise { + return runAsUser(this.postgres, team.owner, "admin", async (query) => { + const [row] = (await query( + `INSERT INTO tournament_teams (tournament_id, team_id, name) + SELECT $1, id, name FROM teams WHERE id = $2 RETURNING id`, + [tournamentId, team.id], + )) as Array<{ id: string }>; + return row.id; + }); + } + + // Registers `teamCount` Wingman-sized teams (owner + one mate) and walks the + // tournament to Live, at which point stage 1 is seeded and scheduled. + async launch( + stages: Array, + teamCount: number, + ): Promise<{ id: string; organizer: string; stageIds: Array }> { + const tournament = await this.createTournament(stages); + await this.setStatus(tournament.id, tournament.organizer, "RegistrationOpen"); + for (let i = 0; i < teamCount; i++) { + await this.registerTeam(tournament.id, await this.fx.team(1)); + } + await this.setStatus( + tournament.id, + tournament.organizer, + "RegistrationClosed", + ); + await this.setStatus(tournament.id, tournament.organizer, "Live"); + return tournament; + } + + getBrackets(stageId: string): Promise> { + return this.postgres.query>( + `SELECT id, round, match_number, "group", match_id, + tournament_team_id_1, tournament_team_id_2, finished + FROM tournament_brackets + WHERE tournament_stage_id = $1 + ORDER BY round, "group", match_number`, + [stageId], + ); + } + + winMatch( + matchId: string, + lineup: "lineup_1_id" | "lineup_2_id" = "lineup_1_id", + ): Promise { + return this.postgres.query( + `UPDATE matches SET winning_lineup_id = ${lineup} WHERE id = $1`, + [matchId], + ); + } + + // Wins every unfinished scheduled match of a round, lineup 1 taking it, + // one at a time so per-match side effects (pool assignment, scheduling the + // next round) run exactly as they would in production. + async playRound(stageId: string, round: number): Promise { + const brackets = await this.postgres.query>( + `SELECT match_id FROM tournament_brackets + WHERE tournament_stage_id = $1 AND round = $2 + AND match_id IS NOT NULL AND finished = false + ORDER BY match_number`, + [stageId, round], + ); + for (const bracket of brackets) { + await this.winMatch(bracket.match_id); + } + return brackets.length; + } + + async tournamentStatus(id: string): Promise { + const [row] = await this.postgres.query>( + "SELECT status FROM tournaments WHERE id = $1", + [id], + ); + return row.status; + } + + stageResults( + stageId: string, + ): Promise> { + return this.postgres.query< + Array<{ tournament_team_id: string; wins: number; losses: number }> + >( + `SELECT tournament_team_id, wins, losses FROM v_team_stage_results + WHERE tournament_stage_id = $1 ORDER BY wins DESC, losses ASC`, + [stageId], + ); + } +} diff --git a/test/views.spec.ts b/test/views.spec.ts new file mode 100644 index 00000000..cf0663a8 --- /dev/null +++ b/test/views.spec.ts @@ -0,0 +1,499 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Exercises the read-side SQL the app displays: the HLTV rating view, the +// clutch feed, the player ELO ledger view and profile aggregation +// (get_player_elo), team rank averages, team reputation, and the leaderboard +// entry points. These are pure reads — regressions produce wrong numbers, not +// errors, so nothing else would catch them. +describe("read-side views and aggregations (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + + beforeAll(async () => { + db = await bootMigratedDb("ViewsTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199950000000n); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM team_scrim_requests"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + await postgres.query("DELETE FROM seasons"); + await postgres.query( + "DELETE FROM settings WHERE name = 'public.seasons_enabled'", + ); + }); + + const T = (minutesAgo: number) => + new Date(Date.now() - minutesAgo * 60_000).toISOString(); + + describe("v_player_match_map_hltv", () => { + it("computes per-round rates and the HLTV 2.0 rating from stored stats", async () => { + const ctx = await fx.bareMatch(); + const [ace, victimOne, victimTwo] = await fx.players(3); + + await fx.kill(ctx, ace, victimOne, { round: 1, time: T(30) }); + await fx.kill(ctx, ace, victimTwo, { round: 1, time: T(29) }); + await fx.kill(ctx, ace, victimOne, { round: 2, time: T(20) }); + // Per-hit damage is capped at the victim's health by the recompute, so + // stay under 100 per event. + await fx.damage(ctx, ace, victimOne, 80, { round: 1 }); + await fx.damage(ctx, ace, victimTwo, 100, { round: 2 }); + await fx.round(ctx.mapId, 1, { time: T(25) }); + await fx.round(ctx.mapId, 2, { time: T(15) }); + + const [row] = await postgres.query< + Array<{ + rounds_played: number; + kast_pct: string; + hltv_rating: string; + kpr: string; + dpr: string; + adr: string; + }> + >( + "SELECT * FROM v_player_match_map_hltv WHERE match_map_id = $1 AND steam_id = $2", + [ctx.mapId, ace], + ); + + expect(Number(row.rounds_played)).toBe(2); + expect(Number(row.kpr)).toBeCloseTo(1.5, 3); + expect(Number(row.dpr)).toBe(0); + expect(Number(row.adr)).toBeCloseTo(90, 1); + // Killed in both rounds: full KAST. + expect(Number(row.kast_pct)).toBe(100); + + // Same formula the view encodes, from the same inputs. + const kastPct = 100; + const kpr = 3 / 2; + const dpr = 0; + const apr = 0; + const adr = 180 / 2; + const expectedRating = + 0.0073 * kastPct + + 0.3591 * kpr - + 0.5329 * dpr + + 0.2372 * (2.13 * kpr + 0.42 * apr - 0.41) + + 0.0032 * adr + + 0.1587; + expect(Number(row.hltv_rating)).toBeCloseTo( + Math.round(expectedRating * 100) / 100, + 2, + ); + + const [victimRow] = await postgres.query< + Array<{ dpr: string; kast_pct: string }> + >( + "SELECT * FROM v_player_match_map_hltv WHERE match_map_id = $1 AND steam_id = $2", + [ctx.mapId, victimOne], + ); + // Died in both rounds without impact: 1.0 deaths per round, 0 KAST. + expect(Number(victimRow.dpr)).toBeCloseTo(1, 3); + expect(Number(victimRow.kast_pct)).toBe(0); + }); + }); + + describe("v_match_clutches", () => { + it("surfaces detected clutches per finalized round", async () => { + const match = await fx.match({ type: "Wingman", mr: 8, mapVeto: true }); + const [a, b, c, d] = await fx.players(4); + await fx.lineupPlayer(match.lineup_1_id, a); + await fx.lineupPlayer(match.lineup_1_id, b); + await fx.lineupPlayer(match.lineup_2_id, c); + await fx.lineupPlayer(match.lineup_2_id, d); + const [map] = await postgres.query>( + `INSERT INTO match_maps (match_id, map_id, "order") + SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1 RETURNING id`, + [match.id], + ); + const ctx = { matchId: match.id, mapId: map.id }; + + await fx.kill(ctx, c, b, { round: 1, time: T(10), attackerTeam: "TERRORIST", victimTeam: "CT" }); + await fx.kill(ctx, a, c, { round: 1, time: T(9), attackerTeam: "CT", victimTeam: "TERRORIST" }); + await fx.kill(ctx, a, d, { round: 1, time: T(8), attackerTeam: "CT", victimTeam: "TERRORIST" }); + await fx.round(ctx.mapId, 1, { winningSide: "CT", time: T(7) }); + + const clutches = await postgres.query< + Array<{ + clutcher_steam_id: string; + against_count: number; + outcome: string; + round: number; + }> + >("SELECT * FROM v_match_clutches WHERE match_id = $1", [match.id]); + + expect(clutches.length).toBe(1); + expect(clutches[0]).toMatchObject({ + clutcher_steam_id: a, + outcome: "won", + }); + expect(Number(clutches[0].against_count)).toBe(2); + }); + }); + + // A finished 1v1 with ELO generated, reused by the ledger and profile tests. + const ratedDuel = async (a: string, b: string, endedDaysAgo = 1) => { + const match = await fx.match({ type: "Duel" }); + await fx.lineupPlayer(match.lineup_1_id, a); + await fx.lineupPlayer(match.lineup_2_id, b); + await postgres.query( + "UPDATE matches SET winning_lineup_id = lineup_1_id WHERE id = $1", + [match.id], + ); + await postgres.query( + "UPDATE matches SET ended_at = now() - make_interval(days => $2) WHERE id = $1", + [match.id, endedDaysAgo], + ); + await postgres.query("SELECT generate_player_elo_for_match($1)", [ + match.id, + ]); + return match; + }; + + describe("v_player_elo and get_player_elo", () => { + it("the ledger view maps wins/losses and before/after ratings", async () => { + const [a, b] = await fx.players(2); + const match = await ratedDuel(a, b); + + const rows = await postgres.query< + Array<{ + player_steam_id: string; + match_result: string; + current_elo: number; + updated_elo: number; + elo_change: number; + }> + >("SELECT * FROM v_player_elo WHERE match_id = $1", [match.id]); + + const winner = rows.find((r) => r.player_steam_id === a)!; + const loser = rows.find((r) => r.player_steam_id === b)!; + expect(winner.match_result).toBe("win"); + expect(loser.match_result).toBe("loss"); + // current_elo is the pre-match rating; updated_elo the post-match one. + expect(Number(winner.current_elo)).toBe(5000); + expect(Number(winner.updated_elo)).toBe( + 5000 + Number(winner.elo_change), + ); + }); + + it("profile aggregation returns per-type ladders (seasons off)", async () => { + const [a, b] = await fx.players(2); + await ratedDuel(a, b); + + const [profile] = await postgres.query>( + "SELECT get_player_elo(p) AS elo FROM players p WHERE steam_id = $1", + [a], + ); + expect(profile.elo.duel).toBeGreaterThan(5000); + // Unplayed types stay null rather than defaulting. + expect(profile.elo).toMatchObject({ competitive: null, wingman: null }); + }); + + it("profile aggregation switches to season + tournament tracks (seasons on)", async () => { + await fx.enableSeasons(); + await fx.season("2025-01-01", null); // active season covers now() + const [a, b] = await fx.players(2); + await ratedDuel(a, b); + + const [profile] = await postgres.query< + Array<{ elo: Record }> + >("SELECT get_player_elo(p) AS elo FROM players p WHERE steam_id = $1", [ + a, + ]); + expect(profile.elo.duel).toBeGreaterThan(5000); // active-season ladder + expect(profile.elo.tournament_duel).toBeNull(); // no tournament matches yet + }); + }); + + describe("v_team_ranks", () => { + it("averages the displayed rating sources across the roster, ignoring gaps", async () => { + const team = await fx.team(1); + const roster = await postgres.query< + Array<{ player_steam_id: string }> + >("SELECT player_steam_id FROM team_roster WHERE team_id = $1 ORDER BY player_steam_id", [ + team.id, + ]); + const [p1, p2] = roster.map((r) => r.player_steam_id); + + // Competitive elo rows for both (via the ledger the view actually reads). + const { matchId } = await fx.bareMatch(T(60)); + await postgres.query( + `INSERT INTO player_elo (steam_id, match_id, type, "current", change, created_at) + VALUES ($1, $3, 'Competitive', 6000, 0, now() - interval '1 hour'), + ($2, $3, 'Competitive', 4000, 0, now() - interval '1 hour')`, + [p1, p2, matchId], + ); + // Faceit data for only one player: the other must not drag the average. + await postgres.query( + "UPDATE players SET faceit_elo = 2000, faceit_skill_level = 8 WHERE steam_id = $1", + [p1], + ); + + const [ranks] = await postgres.query< + Array<{ + roster_size: number; + avg_elo: number; + min_elo: number; + max_elo: number; + avg_faceit_elo: number | null; + avg_faceit_level: number | null; + }> + >("SELECT * FROM v_team_ranks WHERE team_id = $1", [team.id]); + + expect(Number(ranks.roster_size)).toBe(2); + expect(Number(ranks.avg_elo)).toBe(5000); + expect(Number(ranks.min_elo)).toBe(4000); + expect(Number(ranks.max_elo)).toBe(6000); + expect(Number(ranks.avg_faceit_elo)).toBe(2000); + expect(Number(ranks.avg_faceit_level)).toBe(8); + }); + }); + + describe("v_team_reputation", () => { + const scrimRequest = async ( + fromTeam: { id: string; owner: string }, + toTeam: { id: string; owner: string }, + ) => { + const [row] = await postgres.query>( + `INSERT INTO team_scrim_requests + (from_team_id, to_team_id, status, requested_by_steam_id, awaiting_team_id, + proposed_scheduled_at, expires_at) + VALUES ($1, $2, 'Matched', $3, $2, now() + interval '1 day', now() + interval '12 hours') + RETURNING id`, + [fromTeam.id, toTeam.id, fromTeam.owner], + ); + return row.id; + }; + + const scrimMatch = async ( + teamA: { id: string }, + teamB: { id: string }, + requestId: string, + ) => { + const match = await fx.match({ type: "Wingman", mr: 8, mapVeto: true }); + await postgres.query( + "UPDATE match_lineups SET team_id = $1 WHERE id = $2", + [teamA.id, match.lineup_1_id], + ); + await postgres.query( + "UPDATE match_lineups SET team_id = $1 WHERE id = $2", + [teamB.id, match.lineup_2_id], + ); + await postgres.query( + "UPDATE team_scrim_requests SET match_id = $1 WHERE id = $2", + [match.id, requestId], + ); + return match; + }; + + const reputation = async (teamId: string) => { + const [row] = await postgres.query< + Array<{ + scrims_completed: number; + no_shows: number; + late_cancels: number; + }> + >("SELECT * FROM v_team_reputation WHERE team_id = $1", [teamId]); + return row; + }; + + it("counts completed scrims for both teams", async () => { + const teamA = await fx.team(1); + const teamB = await fx.team(1); + const request = await scrimRequest(teamA, teamB); + const match = await scrimMatch(teamA, teamB, request); + + await postgres.query( + "UPDATE matches SET winning_lineup_id = lineup_1_id WHERE id = $1", + [match.id], + ); + + expect(Number((await reputation(teamA.id)).scrims_completed)).toBe(1); + expect(Number((await reputation(teamB.id)).scrims_completed)).toBe(1); + }); + + it("pins a no-show on the team that never checked in, even after match GC", async () => { + const teamA = await fx.team(1); + const teamB = await fx.team(1); + const request = await scrimRequest(teamA, teamB); + const match = await scrimMatch(teamA, teamB, request); + + // Team A checked in; team B never showed. The match is canceled and + // later garbage collected (deleted), leaving only the frozen snapshot. + await postgres.query( + `UPDATE match_lineup_players SET checked_in = true + WHERE match_lineup_id = $1 AND steam_id = $2`, + [match.lineup_1_id, teamA.owner], + ); + await postgres.query( + "UPDATE matches SET status = 'Canceled' WHERE id = $1", + [match.id], + ); + await postgres.query("DELETE FROM matches WHERE id = $1", [match.id]); + + expect(Number((await reputation(teamA.id)).no_shows)).toBe(0); + expect(Number((await reputation(teamB.id)).no_shows)).toBe(1); + }); + + it("charges late cancels only to the team that bailed", async () => { + const teamA = await fx.team(1); + const teamB = await fx.team(1); + const request = await scrimRequest(teamA, teamB); + await scrimMatch(teamA, teamB, request); + + await postgres.query( + `UPDATE team_scrim_requests + SET status = 'Cancelled', canceled_late = true, canceled_by_team_id = $2 + WHERE id = $1`, + [request, teamA.id], + ); + + expect(Number((await reputation(teamA.id)).late_cancels)).toBe(1); + expect(Number((await reputation(teamB.id)).late_cancels)).toBe(0); + }); + }); + + describe("get_leaderboard", () => { + type LeaderboardRow = { + player_steam_id: string; + value: number; + secondary_value: number | null; + tertiary_value: number | null; + matches_played: number; + }; + + const leaderboard = (category: string, windowDays: number, type?: string) => + postgres.query>( + "SELECT * FROM get_leaderboard($1, $2, $3)", + [category, windowDays, type ?? null], + ); + + // A finished '5stack' match with a materialized map to hang kills on. The + // stat categories inner-join match_options, so bareMatch (optionless, the + // demo-import shape) would be invisible to them. + const statMatch = async () => { + const { poolId } = await fx.mapPool(1); + const match = await fx.match({ mapPoolId: poolId }); + const [map] = await postgres.query>( + "SELECT id FROM match_maps WHERE match_id = $1", + [match.id], + ); + return { match, ctx: { matchId: match.id, mapId: map.id } }; + }; + + it("ranks the elo ladder and per-player stats categories", async () => { + const [a, b] = await fx.players(2); + await ratedDuel(a, b, 2); + await ratedDuel(a, b, 1); // a wins twice: clearly ahead + + const elo = await leaderboard("elo", 30, "Duel"); + expect(elo.length).toBe(2); + expect(elo[0].player_steam_id).toBe(a); + expect(Number(elo[0].value)).toBeGreaterThan(Number(elo[1].value)); + }); + + it("best_kdr divides kills by deaths, falling back to kill count for the deathless", async () => { + const { ctx } = await statMatch(); + const [ace, feeder, cleaner, target] = await fx.players(4); + for (const round of [1, 2, 3]) { + await fx.kill(ctx, ace, feeder, { round }); + } + await fx.kill(ctx, feeder, ace); + await fx.kill(ctx, cleaner, target); + await fx.kill(ctx, cleaner, target, { round: 2 }); + + const rows = await leaderboard("best_kdr", 30, "Competitive"); + // ace 3/1, cleaner deathless (value = raw kill count 2), feeder 1/3. + expect(rows.map((r) => r.player_steam_id)).toEqual([ + ace, + cleaner, + feeder, + ]); + const byId = new Map(rows.map((r) => [r.player_steam_id, r])); + expect(Number(byId.get(ace)!.value)).toBe(3); + expect(Number(byId.get(ace)!.secondary_value)).toBe(3); // kills + expect(Number(byId.get(ace)!.tertiary_value)).toBe(1); // deaths + expect(Number(byId.get(cleaner)!.value)).toBe(2); + expect(Number(byId.get(cleaner)!.tertiary_value)).toBe(0); + expect(Number(byId.get(feeder)!.value)).toBeCloseTo(0.33, 2); + // Never got a kill: not on the board, despite the deaths. + expect(byId.has(target)).toBe(false); + }); + + it("best_win_rate is the finished-match win percentage with win/loss detail", async () => { + const [champ, rival] = await fx.players(2); + await ratedDuel(champ, rival); // ratedDuel: first player wins + await ratedDuel(champ, rival); + await ratedDuel(rival, champ); + + const rows = await leaderboard("best_win_rate", 30, "Duel"); + expect(rows.map((r) => r.player_steam_id)).toEqual([champ, rival]); + const [top, bottom] = rows; + expect(Number(top.value)).toBeCloseTo(66.67, 2); + expect(Number(top.secondary_value)).toBe(2); // wins + expect(Number(top.tertiary_value)).toBe(1); // losses + expect(Number(top.matches_played)).toBe(3); + expect(Number(bottom.value)).toBeCloseTo(33.33, 2); + }); + + it("highest_hs_pct ranks headshot ratios from the kill feed", async () => { + const { ctx } = await statMatch(); + const [surgeon, sprayer, victim] = await fx.players(3); + await fx.kill(ctx, surgeon, victim, { headshot: true }); + await fx.kill(ctx, sprayer, victim, { headshot: true }); + await fx.kill(ctx, sprayer, victim, { headshot: false, round: 2 }); + await fx.kill(ctx, sprayer, victim, { headshot: false, round: 3 }); + + const rows = await leaderboard("highest_hs_pct", 30, "Competitive"); + expect(rows.map((r) => r.player_steam_id)).toEqual([surgeon, sprayer]); + expect(Number(rows[0].value)).toBe(100); + expect(Number(rows[0].secondary_value)).toBe(1); // total kills + expect(Number(rows[1].value)).toBeCloseTo(33.33, 2); + expect(Number(rows[1].secondary_value)).toBe(3); + }); + + it("stat categories respect the day window, with 0 meaning all time", async () => { + const { ctx } = await statMatch(); + const [a, b] = await fx.players(2); + await fx.kill(ctx, a, b, { time: T(60 * 24 * 40) }); // 40 days back + + expect((await leaderboard("best_kdr", 30, "Competitive")).length).toBe(0); + expect((await leaderboard("best_kdr", 0, "Competitive")).length).toBe(1); + }); + + it("get_player_leaderboard_rank locates a player inside the ladder", async () => { + const [champ, rival] = await fx.players(2); + await ratedDuel(champ, rival); + + const [rank] = await postgres.query< + Array<{ rank: number; total: number; value: number }> + >( + "SELECT * FROM get_player_leaderboard_rank('elo', 30, $1, 'Duel')", + [rival], + ); + expect(Number(rank.rank)).toBe(2); + expect(Number(rank.total)).toBe(2); + }); + + it("rejects unknown categories loudly instead of returning an empty ladder", async () => { + await expect( + postgres.query("SELECT * FROM get_leaderboard('bogus', 30, NULL)"), + ).rejects.toThrow(/Invalid category/); + }); + }); +}); diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 00000000..b203dbe6 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false + }, + "include": ["test/**/*", "src/**/*.d.ts"] +}