Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions hasura/triggers/tournament_team_roster.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 $$
Expand Down
11 changes: 10 additions & 1 deletion src/events/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/events/events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const [row] = await this.postgres.query<Array<{ ok: boolean }>>(
`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,
Expand Down
6 changes: 6 additions & 0 deletions test/jest-sql.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,10 @@ module.exports = {
globalSetup: "<rootDir>/../test/utils/jest-global-setup.ts",
globalTeardown: "<rootDir>/../test/utils/jest-global-teardown.ts",
testSequencer: "<rootDir>/../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,
};
26 changes: 26 additions & 0 deletions test/map-veto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
133 changes: 122 additions & 11 deletions test/tournaments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ id: string }>>(
`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<Array<{ id: string }>>(
`INSERT INTO tournaments (name, start, organizer_steam_id, match_options_id, status)
Expand Down Expand Up @@ -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);
Expand All @@ -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<Array<{ id: string }>>(
"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 });
Expand All @@ -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);
});
Expand Down Expand Up @@ -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<Array<{ count: string }>>(
"SELECT COUNT(*) FROM tournament_team_roster WHERE tournament_team_id = $1",
[teamId],
);
expect(Number(countRow.count)).toBe(2);

const [row] = await postgres.query<Array<{ eligible_at: Date | null }>>(
"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<Array<{ id: string }>>(
"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();
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Loading