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
4 changes: 3 additions & 1 deletion hasura/functions/tournaments/can_join_tournament.sql
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ BEGIN
-- Otherwise they can still register another team they manage that is not
-- already in this tournament (e.g. an A team and a B team that share
-- members): being on another team's roster does not block registration.
-- The team's owner must not already have one here, or tbi_tournament_team
-- refuses the insert.
RETURN EXISTS (
SELECT 1
FROM public.teams t
Expand All @@ -67,7 +69,7 @@ BEGIN
SELECT 1
FROM public.tournament_teams tt
WHERE tt.tournament_id = tournament.id
AND tt.team_id = t.id
AND (tt.team_id = t.id OR tt.owner_steam_id = t.owner_steam_id)
)
);
END;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,6 @@ BEGIN
-- so drafting them would abort the whole transition on a key violation.
--
-- Owning a team counts as being in the tournament even with no roster row.
-- tournament_teams is UNIQUE (owner_steam_id, tournament_id) and every
-- generated team takes its top-rated player as owner, so drafting one of
-- these would raise a duplicate key -- inside tau_tournaments, which rolls
-- the entire RegistrationOpen -> RegistrationClosed transition back and
-- fails identically on every retry, with no way for the organizer out.
--
-- Both guards are PER MEMBER: an ineligible member shrinks their party by
-- one rather than knocking the whole party out. The rest of the party did
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,9 @@ BEGIN
AND public.player_meets_tournament_requirements(_tournament_id, fa.player_steam_id)
-- Already playing, under either identity. The roster key is unique per
-- (tournament, player) so the insert below would abort the caller's whole
-- statement, and owning a team collides with
-- UNIQUE (owner_steam_id, tournament_id) -- the collision that hard-stalled
-- the draft once already. Per member: an ineligible member shrinks
-- their party, it does not disqualify the party.
-- statement, and owning a team counts as being in the tournament. Per
-- member: an ineligible member shrinks their party, it does not
-- disqualify the party.
AND NOT EXISTS (
SELECT 1 FROM public.tournament_team_roster ttr
WHERE ttr.tournament_id = _tournament_id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tournament_teams_creator_steam_id_tournament_id_key'
) THEN
ALTER TABLE "public"."tournament_teams"
ADD CONSTRAINT "tournament_teams_creator_steam_id_tournament_id_key"
UNIQUE ("owner_steam_id", "tournament_id");
END IF;
END $$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Replaced by a check in tbi_tournament_team: owner_steam_id is whoever created
-- the row (a Hasura insert preset), so an organizer adding teams by hand owns
-- all of them. Everyone else stays at one team per tournament, and a player can
-- only play once per tournament through tournament_roster_pkey.
ALTER TABLE "public"."tournament_teams"
DROP CONSTRAINT IF EXISTS "tournament_teams_creator_steam_id_tournament_id_key";
9 changes: 3 additions & 6 deletions hasura/triggers/tournament_free_agents.sql
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,9 @@ BEGIN
MESSAGE = 'Player does not meet this tournament''s entry requirements';
END IF;

-- A team owner is already in the tournament. The draft makes its top-rated
-- player the generated team's owner, and tournament_teams is
-- UNIQUE (owner_steam_id, tournament_id), so letting an owner into the pool
-- sets up a duplicate key that aborts the whole registration-close
-- transition. The draft skips them too; this only refuses the join outright
-- so the pool never shows a slot that could not be honoured.
-- A team owner is already in the tournament. The draft skips them too; this
-- only refuses the join outright so the pool never shows a slot that could
-- not be honoured.
IF EXISTS (
SELECT 1
FROM public.tournament_teams tt
Expand Down
23 changes: 23 additions & 0 deletions hasura/triggers/tournament_teams.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ BEGIN
NEW.captain_steam_id = COALESCE(NEW.owner_steam_id, _session_steam_id);
END IF;

-- One team per owner per tournament, except for organizers, who add teams
-- by hand and own every one of them through the owner_steam_id preset.
-- The lock stands in for the unique constraint this replaced: without it
-- two concurrent registrations both pass the check.
IF (_session ->> 'x-hasura-role') IS NOT NULL
AND NEW.owner_steam_id IS NOT NULL
AND NOT is_tournament_organizer(tournament, _session) THEN
PERFORM pg_advisory_xact_lock(
hashtext('tournament_team_owner'),
hashtext(NEW.tournament_id::text || ':' || NEW.owner_steam_id::text)
);

IF EXISTS (
SELECT 1
FROM tournament_teams tt
WHERE tt.tournament_id = NEW.tournament_id
AND tt.owner_steam_id = NEW.owner_steam_id
) THEN
RAISE EXCEPTION USING ERRCODE = '22000',
MESSAGE = 'You already have a team in this tournament';
END IF;
END IF;

-- Registering after the window opened counts as checked in: a team that
-- signs up at T-30 must not be swept at T-15 for failing to confirm a
-- prompt it was never shown.
Expand Down
230 changes: 230 additions & 0 deletions test/tournament-roster-duplicate.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PostgresService } from "./../src/postgres/postgres.service";
import { Fixtures } from "./utils/fixtures";
import { TournamentFixtures } from "./utils/tournament-fixtures";
import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db";

// Reproduces the "duplicate key value violates unique constraint
Expand Down Expand Up @@ -141,6 +142,235 @@ describe("tournament roster duplicate key (SQL-driven)", () => {
expect(teamBSize).toBeGreaterThan(0);
});

// Hasura presets owner_steam_id to the caller on insert, so every team an
// organizer adds by hand is owned by the organizer.
it("an organizer can add several tournament-only teams", async () => {
const { id: tournamentId, organizer } = await createTournament();
const [first, second] = [await fx.player(), await fx.player()];

for (const [index, player] of [first, second].entries()) {
await runAsUser(postgres, organizer, "user", async (query) => {
const [tt] = (await query(
`INSERT INTO tournament_teams (tournament_id, team_id, name, owner_steam_id, captain_steam_id)
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
[tournamentId, fx.nextName(`adhoc${index}`), organizer, player],
)) as Array<{ id: string }>;

await query(
`INSERT INTO tournament_team_roster (tournament_team_id, player_steam_id, tournament_id)
VALUES ($1, $2, $3)`,
[tt.id, player, tournamentId],
);
});
}

const [{ count }] = await postgres.query<Array<{ count: number }>>(
`SELECT count(*)::int AS count FROM tournament_teams WHERE tournament_id = $1`,
[tournamentId],
);
expect(count).toBe(2);
});

it("an admin can register two existing teams with the same owner", async () => {
const { id: tournamentId } = await createTournament();
const teamA = await fx.team(1);
const [teamB] = await postgres.query<Array<{ id: string }>>(
"INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id",
[fx.nextName("team"), teamA.owner],
);
const mate = await fx.player();
await runAsUser(postgres, teamA.owner, "admin", (query) =>
query(
"INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Starter')",
[teamB.id, mate],
),
);

await registerRealTeam(tournamentId, teamA);
await registerRealTeam(tournamentId, { id: teamB.id, owner: teamA.owner });

const [{ count }] = await postgres.query<Array<{ count: number }>>(
`SELECT count(*)::int AS count FROM tournament_teams WHERE tournament_id = $1`,
[tournamentId],
);
expect(count).toBe(2);
});

const addAdhocTeam = (
tournamentId: string,
steamId: string,
role: string,
name: string,
) =>
runAsUser(postgres, steamId, role, (query) =>
query(
`INSERT INTO tournament_teams (tournament_id, team_id, name, owner_steam_id)
VALUES ($1, NULL, $2, $3)`,
[tournamentId, name, steamId],
),
);

it("a co-organizer can add several teams", async () => {
const { id: tournamentId } = await createTournament();
const coOrganizer = await fx.player();
await postgres.query(
"INSERT INTO tournament_organizers (tournament_id, steam_id) VALUES ($1, $2)",
[tournamentId, coOrganizer],
);

await addAdhocTeam(tournamentId, coOrganizer, "user", "first");
await addAdhocTeam(tournamentId, coOrganizer, "user", "second");

const [{ count }] = await postgres.query<Array<{ count: number }>>(
`SELECT count(*)::int AS count FROM tournament_teams WHERE tournament_id = $1`,
[tournamentId],
);
expect(count).toBe(2);
});

it("a regular user cannot register a second team", async () => {
const { id: tournamentId } = await createTournament();
const player = await fx.player();

await addAdhocTeam(tournamentId, player, "user", "first");
await expect(
addAdhocTeam(tournamentId, player, "user", "second"),
).rejects.toThrow(/already have a team in this tournament/);
});

it("a regular user cannot register a second team they own", async () => {
const { id: tournamentId } = await createTournament();
const teamA = await fx.team(1);
const [teamB] = await postgres.query<Array<{ id: string }>>(
"INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id",
[fx.nextName("teamb"), teamA.owner],
);

const register = (teamId: string) =>
runAsUser(postgres, teamA.owner, "user", (query) =>
query(
`INSERT INTO tournament_teams (tournament_id, team_id, name)
SELECT $1, id, name FROM teams WHERE id = $2`,
[tournamentId, teamId],
),
);

await register(teamA.id);
await expect(register(teamB.id)).rejects.toThrow(
/already have a team in this tournament/,
);
});

describe("can_join_tournament", () => {
let tfx: TournamentFixtures;

beforeAll(() => {
tfx = new TournamentFixtures(postgres, fx);
});

const openTournament = async () => {
const tournament = await tfx.createTournament([
{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 },
]);
await tfx.setStatus(
tournament.id,
tournament.organizer,
"RegistrationOpen",
);
return tournament.id;
};

const canJoin = async (
tournamentId: string,
steamId: string,
role = "user",
) => {
const [row] = await postgres.query<Array<{ can_join: boolean }>>(
`SELECT can_join_tournament(t, $2::json) AS can_join
FROM tournaments t WHERE t.id = $1`,
[
tournamentId,
JSON.stringify({
"x-hasura-role": role,
"x-hasura-user-id": steamId,
}),
],
);
return row.can_join;
};

const ownedTeam = async (owner: string) => {
const [team] = await postgres.query<Array<{ id: string }>>(
"INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id",
[fx.nextName(`owned${owner}`), owner],
);
return { id: team.id, owner };
};

const makeTeamAdmin = async (
team: { id: string; owner: string },
steamId: string,
) => {
await runAsUser(postgres, team.owner, "admin", (query) =>
query(
"INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Starter')",
[team.id, steamId],
),
);
await postgres.query(
"UPDATE team_roster SET role = 'Admin' WHERE team_id = $1 AND player_steam_id = $2",
[team.id, steamId],
);
};

it("lets a player with no team join", async () => {
const tournamentId = await openTournament();
expect(await canJoin(tournamentId, await fx.player())).toBe(true);
});

it("does not offer a second team the player also owns", async () => {
const tournamentId = await openTournament();
const teamA = await fx.team(1);
await ownedTeam(teamA.owner);
await tfx.registerTeam(tournamentId, teamA);

expect(await canJoin(tournamentId, teamA.owner)).toBe(false);
});

it("offers a second team the player manages but someone else owns", async () => {
const tournamentId = await openTournament();
const teamA = await fx.team(1);
await tfx.registerTeam(tournamentId, teamA);
const teamB = await ownedTeam(await fx.player());
await makeTeamAdmin(teamB, teamA.owner);

expect(await canJoin(tournamentId, teamA.owner)).toBe(true);
});

it("does not offer a team whose owner already has a team in the tournament", async () => {
const tournamentId = await openTournament();
const teamA = await fx.team(1);
await tfx.registerTeam(tournamentId, teamA);
const teamC = await fx.team(1);
await tfx.registerTeam(tournamentId, teamC);
const teamB = await ownedTeam(teamC.owner);
await makeTeamAdmin(teamB, teamA.owner);

expect(await canJoin(tournamentId, teamA.owner)).toBe(false);
});

it("always lets a tournament organizer add teams", async () => {
const tournamentId = await openTournament();
const teamA = await fx.team(1);
await ownedTeam(teamA.owner);
await tfx.registerTeam(tournamentId, teamA);

expect(
await canJoin(tournamentId, teamA.owner, "tournament_organizer"),
).toBe(true);
});
});

const memberIds = async (teamId: string) => {
const rows = await postgres.query<Array<{ player_steam_id: string }>>(
"SELECT player_steam_id FROM team_roster WHERE team_id = $1",
Expand Down
Loading