diff --git a/hasura/functions/match/map-veto/create_match_map_from_veto.sql b/hasura/functions/match/map-veto/create_match_map_from_veto.sql index ec63eb46..16294eb7 100644 --- a/hasura/functions/match/map-veto/create_match_map_from_veto.sql +++ b/hasura/functions/match/map-veto/create_match_map_from_veto.sql @@ -52,9 +52,12 @@ BEGIN -- Determine the lineup ID for veto picking SELECT * INTO lineup_id FROM get_map_veto_picking_lineup_id(_match); - -- Insert the leftover map into match_map_veto_picks table - INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id) - VALUES (match_map_veto_pick.match_id, 'Decider', lineup_id, available_maps[1]); + -- Insert the leftover map into match_map_veto_picks table. + -- created_at uses clock_timestamp() (not the column default now(), which is + -- frozen at transaction start) so the Decider always sorts AFTER the pick + -- that triggered it; display ordering is by created_at with no tiebreaker. + INSERT INTO match_map_veto_picks (match_id, type, match_lineup_id, map_id, created_at) + VALUES (match_map_veto_pick.match_id, 'Decider', lineup_id, available_maps[1], clock_timestamp()); -- Update the total number of maps for the match and insert the leftover map into match_maps SELECT count(*) INTO total_maps FROM match_maps WHERE match_id = match_map_veto_pick.match_id; diff --git a/hasura/triggers/tournament_team_roster.sql b/hasura/triggers/tournament_team_roster.sql index c6777ed3..8dd0cb8c 100644 --- a/hasura/triggers/tournament_team_roster.sql +++ b/hasura/triggers/tournament_team_roster.sql @@ -18,6 +18,56 @@ DROP TRIGGER IF EXISTS taiud_tournament_team_roster ON public.tournament_team_ro CREATE TRIGGER taiud_tournament_team_roster AFTER INSERT OR UPDATE OR DELETE ON public.tournament_team_roster FOR EACH ROW EXECUTE FUNCTION public.taiud_tournament_team_roster(); +CREATE OR REPLACE FUNCTION public.tbd_tournament_team_roster() RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +DECLARE + _tournament public.tournaments; + _min_players int; + _roster_count int; +BEGIN + SELECT t.* INTO _tournament + FROM tournament_teams tt + JOIN tournaments t ON t.id = tt.tournament_id + WHERE tt.id = OLD.tournament_team_id; + + -- When the whole team (or the tournament) is being deleted its + -- tournament_teams row is already gone by the time this cascade fires, so + -- the join finds nothing and we let the roster rows cascade through. + IF NOT FOUND THEN + RETURN OLD; + END IF; + + -- Rosters are only locked once the bracket has been seeded. Before that + -- (Setup / RegistrationOpen) teams edit their lineup freely and dropping + -- below the minimum just makes them ineligible. + IF _tournament.status NOT IN ('RegistrationClosed', 'Live', 'Paused') THEN + RETURN OLD; + END IF; + + _min_players := tournament_min_players_per_lineup(_tournament); + + SELECT COUNT(*) INTO _roster_count + FROM tournament_team_roster ttr + WHERE ttr.tournament_team_id = OLD.tournament_team_id; + + -- Removing this player would strip the team's eligibility and seed while the + -- tournament is underway. A team can only swap a player out if it has a + -- substitute keeping it at or above the minimum lineup. + IF _roster_count - 1 < _min_players THEN + RAISE EXCEPTION USING + ERRCODE = '22000', + MESSAGE = 'Cannot remove player: the team would drop below the minimum lineup of ' || _min_players || ' players while the tournament is underway'; + END IF; + + RETURN OLD; +END; +$$; + +DROP TRIGGER IF EXISTS tbd_tournament_team_roster ON public.tournament_team_roster; +CREATE TRIGGER tbd_tournament_team_roster BEFORE DELETE ON public.tournament_team_roster FOR EACH ROW EXECUTE FUNCTION public.tbd_tournament_team_roster(); + + CREATE OR REPLACE FUNCTION public.tbi_tournament_team_roster() RETURNS TRIGGER LANGUAGE plpgsql AS $$ diff --git a/src/events/events.controller.ts b/src/events/events.controller.ts index c89d39fc..a2ed0a9c 100644 --- a/src/events/events.controller.ts +++ b/src/events/events.controller.ts @@ -381,7 +381,16 @@ export class EventsController { request.user as User | undefined, ); if (!canView) { - throw new NotFoundException("media not found"); + // Exception: a Public/Friends event's banner is publicly viewable so + // link-unfurl crawlers (which carry no session) can render it. Every + // other gallery item — and any Private event — stays gated. + const isBanner = await this.eventsService.isShareableBanner( + eventId, + filename, + ); + if (!isBanner) { + throw new NotFoundException("media not found"); + } } const media = await this.eventsService.getMedia(eventId, filename); diff --git a/src/events/events.service.ts b/src/events/events.service.ts index 6a36ed53..eb839f57 100644 --- a/src/events/events.service.ts +++ b/src/events/events.service.ts @@ -153,6 +153,28 @@ export class EventsService { return row; } + // The event's banner (and its poster frame) is the one media item that must + // be fetchable by an anonymous request — link-unfurl crawlers (Discord, + // Steam, etc.) carry no session and would otherwise get a 404, leaving a + // bannerless card. Only Public/Friends banners qualify; Private events and + // every other gallery item stay behind canView(). + public async isShareableBanner( + eventId: string, + filename: string, + ): Promise { + const [row] = await this.postgres.query>( + `SELECT true AS ok + FROM public.events e + JOIN public.event_media m ON m.id = e.banner_media_id + WHERE e.id = $1 + AND e.visibility IN ('Public', 'Friends') + AND (m.filename = $2 OR m.thumbnail_filename = $2) + LIMIT 1`, + [eventId, filename], + ); + return row?.ok === true; + } + public async getMediaById( eventId: string, mediaId: string, diff --git a/test/jest-sql.config.js b/test/jest-sql.config.js index dd92c880..8681ee18 100644 --- a/test/jest-sql.config.js +++ b/test/jest-sql.config.js @@ -12,4 +12,10 @@ module.exports = { globalSetup: "/../test/utils/jest-global-setup.ts", globalTeardown: "/../test/utils/jest-global-teardown.ts", testSequencer: "/../test/utils/slow-first-sequencer.js", + // Every suite shares one container, so under CI's parallel workers a heavy + // test (full tournament playouts, deep bracket resets) can run well past + // jest's 5s default. Timing out mid-query is doubly bad here: jest abandons + // the in-flight statement while it still holds row locks, so the next test's + // beforeEach cleanup deadlocks against it. Give DB-bound tests real headroom. + testTimeout: 60000, }; diff --git a/test/map-veto.spec.ts b/test/map-veto.spec.ts index e2ca5079..8c284e72 100644 --- a/test/map-veto.spec.ts +++ b/test/map-veto.spec.ts @@ -169,6 +169,32 @@ describe("map veto (SQL-driven)", () => { expect((await vetoState(match.id)).status).toBe("Live"); }); + it("orders the auto-Decider strictly after the ban that triggered it", async () => { + // The Decider is inserted by create_match_map_from_veto inside the SAME + // transaction as the final ban. Taking the created_at default (now(), frozen + // at transaction start) tied it with that ban, and since the veto display + // sorts on created_at with no tiebreaker the Decider could render before the + // ban ("ban, ..., decider, ban"). It now uses clock_timestamp() so it always + // sorts last. Assert strict ordering (a tie would fail this). + const match = await createVetoMatch(1, 3); + + await insertPick(match.id, "Ban", match.lineup_1_id, match.mapIds[0]); + await insertPick(match.id, "Ban", match.lineup_2_id, match.mapIds[1]); + + const [row] = await postgres.query< + Array<{ last_ban: string; decider: string }> + >( + `SELECT max(created_at) FILTER (WHERE type = 'Ban') AS last_ban, + max(created_at) FILTER (WHERE type = 'Decider') AS decider + FROM match_map_veto_picks WHERE match_id = $1`, + [match.id], + ); + + expect(new Date(row.decider).getTime()).toBeGreaterThan( + new Date(row.last_ban).getTime(), + ); + }); + it("runs the BO3 Pick/Side steps and assigns the chosen side to the picking lineup", async () => { const match = await createVetoMatch(3, 4); diff --git a/test/tournaments.spec.ts b/test/tournaments.spec.ts index a021fb67..af85639e 100644 --- a/test/tournaments.spec.ts +++ b/test/tournaments.spec.ts @@ -48,12 +48,14 @@ describe("tournaments (SQL-driven)", () => { const createTournament = async ({ withStage = true, start = "1 day", + substitutes = 0, } = {}) => { 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}' + `INSERT INTO match_options (mr, best_of, type, map_pool_id, map_veto, region_veto, regions, number_of_substitutes) + SELECT 8, 1, 'Wingman', id, false, true, '{TestA}', $1 FROM map_pools WHERE type = 'Wingman' AND seed = true RETURNING id`, + [substitutes], ); const [tournament] = await postgres.query>( `INSERT INTO tournaments (name, start, organizer_steam_id, match_options_id, status) @@ -127,12 +129,12 @@ describe("tournaments (SQL-driven)", () => { ); // Registration through bracket seeding with four eligible teams. - const seedFourTeamCup = async () => { - const tournament = await createTournament(); + const seedFourTeamCup = async ({ substitutes = 0, mates = 1 } = {}) => { + const tournament = await createTournament({ substitutes }); 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()); + teams.push(await createTeam(mates)); } for (const team of teams) { await registerTeam(tournament.id, team); @@ -141,6 +143,36 @@ describe("tournaments (SQL-driven)", () => { return { tournament, teams }; }; + // The tournament_teams id for a registered team. + const getTournamentTeamId = async (tournamentId: string, teamId: string) => { + const [row] = await postgres.query>( + "SELECT id FROM tournament_teams WHERE tournament_id = $1 AND team_id = $2", + [tournamentId, teamId], + ); + return row.id; + }; + + // Removes exactly one non-owner roster member as the team owner. + const removeRosterMate = ( + tournamentId: string, + team: { id: string; owner: string }, + ) => + runAsUser(postgres, team.owner, "admin", (query) => + query( + `DELETE FROM tournament_team_roster + WHERE ctid = ( + SELECT ttr.ctid FROM tournament_team_roster ttr + WHERE ttr.tournament_team_id = ( + SELECT id FROM tournament_teams + WHERE tournament_id = $1 AND team_id = $2 + ) + AND ttr.player_steam_id != $3 + LIMIT 1 + )`, + [tournamentId, team.id, team.owner], + ), + ); + describe("status transition guards", () => { it("cannot open registration without stages", async () => { const t = await createTournament({ withStage: false }); @@ -161,9 +193,10 @@ describe("tournaments (SQL-driven)", () => { const stranger = await seedPlayer(); await expect( runAsUser(postgres, stranger, "user", (query) => - query("UPDATE tournaments SET status = 'RegistrationOpen' WHERE id = $1", [ - t.id, - ]), + query( + "UPDATE tournaments SET status = 'RegistrationOpen' WHERE id = $1", + [t.id], + ), ), ).rejects.toThrow(/Cannot open tournament registration/i); }); @@ -275,6 +308,74 @@ describe("tournaments (SQL-driven)", () => { }); }); + describe("roster lock once the bracket is seeded", () => { + it("cannot drop a roster below the minimum after registration closes", async () => { + const { tournament, teams } = await seedFourTeamCup(); + + await expect(removeRosterMate(tournament.id, teams[0])).rejects.toThrow( + /minimum lineup/i, + ); + + // Eligibility and seed are untouched — the removal never happened. + const teamId = await getTournamentTeamId(tournament.id, teams[0].id); + const [row] = await postgres.query< + Array<{ eligible_at: Date | null; seed: number | null }> + >("SELECT eligible_at, seed FROM tournament_teams WHERE id = $1", [ + teamId, + ]); + expect(row.eligible_at).not.toBeNull(); + expect(row.seed).not.toBeNull(); + }); + + it("cannot drop a roster below the minimum while the tournament is live", async () => { + const { tournament, teams } = await seedFourTeamCup(); + await setStatus(tournament.id, tournament.organizer, "Live"); + + await expect(removeRosterMate(tournament.id, teams[0])).rejects.toThrow( + /minimum lineup/i, + ); + }); + + it("allows swapping a player out when a substitute keeps the lineup at the minimum", async () => { + // Wingman needs two per lineup; one substitute slot lets a three-player + // team drop back to two. + const { tournament, teams } = await seedFourTeamCup({ + substitutes: 1, + mates: 2, + }); + const teamId = await getTournamentTeamId(tournament.id, teams[0].id); + + await removeRosterMate(tournament.id, teams[0]); + + const [countRow] = await postgres.query>( + "SELECT COUNT(*) FROM tournament_team_roster WHERE tournament_team_id = $1", + [teamId], + ); + expect(Number(countRow.count)).toBe(2); + + const [row] = await postgres.query>( + "SELECT eligible_at FROM tournament_teams WHERE id = $1", + [teamId], + ); + expect(row.eligible_at).not.toBeNull(); + }); + + it("still lets an entire team be removed, cascading its roster", async () => { + const { tournament, teams } = await seedFourTeamCup(); + const teamId = await getTournamentTeamId(tournament.id, teams[0].id); + + await runAsUser(postgres, teams[0].owner, "admin", (query) => + query("DELETE FROM tournament_teams WHERE id = $1", [teamId]), + ); + + const rows = await postgres.query>( + "SELECT id FROM tournament_teams WHERE id = $1", + [teamId], + ); + expect(rows.length).toBe(0); + }); + }); + describe("bracket seeding and progression", () => { it("closing registration seeds the single-elimination bracket and schedules round 1", async () => { const { tournament } = await seedFourTeamCup(); @@ -326,7 +427,11 @@ describe("tournaments (SQL-driven)", () => { expect((await getTournament(tournament.id)).status).toBe("Finished"); const trophies = await postgres.query< - Array<{ placement: number; tournament_team_id: string; manual: boolean }> + 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`, @@ -376,7 +481,10 @@ describe("tournaments (SQL-driven)", () => { 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"); + await winMatch( + brackets.find((b) => b.round === 2)!.match_id!, + "lineup_1_id", + ); expect((await getTournament(tournament.id)).status).toBe("Finished"); await expect( @@ -396,7 +504,10 @@ describe("tournaments (SQL-driven)", () => { 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"); + await winMatch( + brackets.find((b) => b.round === 2)!.match_id!, + "lineup_1_id", + ); const count = async () => Number(