Slett en hel turnering, permanent (ADR-086)

Nytt DELETE /orgs/{id}/tournaments/{id}, kun org-eier/admin. Eksplisitt
riktig-rekkefølge-sletting (ikke naiv kaskade) siden to RESTRICT-FK-er
sitter midt inni kaskade-grafen for begge turneringsformater -- verifisert
med fire nye backend-tester mot ekte populerte data. Søppelbøtte-ikon i
begge turnering-headerne med sterk confirm()-tekst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-18 12:59:37 +02:00
parent 82343e522b
commit 207750acba
6 changed files with 454 additions and 1 deletions

View file

@ -8820,6 +8820,88 @@ revet ned.
---
## ADR-086: Slett en hel turnering, permanent (2026-08-18)
Bruker: "Jeg mangler mulighet til å slette HELE turneringen." Undersøkt
FØR bygging: ingen `DELETE`-endepunkt for selve `tournament`-raden fantes
noe sted -- alle eksisterende `DELETE`-endepunkter i `tournaments.py`/
`individual_tournaments.py` treffer kun UNDER-ressurser (én økt, én runde,
ett medlem osv.), og de fleste av DEM nekter i tillegg sletting så snart
det finnes ekte barn (409 "ikke tom"). Genuint manglende funksjonalitet,
ikke bare uklikkbar.
**Autorisasjon: strengere enn resten av turneringsoppsettet, bevisst.**
Alt annet i `tournaments.py` krever kun org-medlemskap (`get_authorized_
org`) -- dokumentert som en bevisst grense i filens egen header-kommentar
("dette er organisator-arbeid"). En hel-turnering-sletting er
kategorisk mer ødeleggende (fjerner ALL ekte score-/matchdata permanent,
ikke tomt oppsett) -- ny, strengere sperre: `is_org_admin` (eier/admin),
samme funksjon `messaging.py` allerede bruker for moderasjon. Håndheves
server-side (403 for vanlige medlemmer); frontend skjuler i tillegg selve
knappen for dem via `/auth/me` sin rolle -- ren UX-hjelp, ikke den reelle
sperren.
**Eksplisitt, riktig-rekkefølge-sletting, IKKE naiv enkelt-setnings-
kaskade.** Skjemaet har to RESTRICT-FK-er MIDT INNI kaskade-grafen, ikke
bare i kantene: `match_participant.team_roster_id` (mens `team_roster`
kaskaderer fra `team`, og `match_participant` OGSÅ kaskaderer fra
`match`→`session` -- en "diamant"), og tilsvarende `tournament_round_
participant.tournament_participant_id` for individuell-formatet. Siden
RESTRICT (ulikt NO ACTION) sjekkes UMIDDELBART, ikke utsettbart, kunne en
enkel `DELETE FROM tournament` feilt avhengig av Postgres sin interne
kaskade-rekkefølge -- KUN når det faktisk finnes ekte kamper/runder (en
tom turnering ville aldri avslørt dette). Løst med eksplisitt sletting i
riktig rekkefølge (fjerner RESTRICT-blokkerende rader FØR det de peker på
forsvinner) i stedet for å stole blindt på kaskaden -- `org_connection`
sin egen transaksjon gjør alt atomisk uansett.
**Spillerne selv (`player`, org-scopet pool) røres ALDRI** -- kun
turneringens egne koblinger til dem (roster/deltakelse) forsvinner.
**Ingen soft-delete/arkivering.** Sjekket: ingen `deleted_at`/`is_
deleted`-mønster finnes noe sted i skjemaet. `tournament.status` sin
`'archived'`-verdi er et rent visningsmerke uten noen reell backend-
oppførsel (ingen skjuling/beskyttelse) -- IKKE en soft-delete-erstatning.
Ekte, permanent `DELETE` er den etablerte normen i denne kodebasen
(speiler `DELETE /rounds/{id}` i `rounds.py`, den nærmeste presedensen
for "slett en hel ting med barn").
**UI:** søppelbøtte-ikon i hoved-header, ved siden av
`TournamentStatusPicker`, i BÅDE `tournament-detail.tsx` (lag-format) og
`individual-tournament-detail.tsx` (individuell) -- identisk mønster i
begge, kun synlig for eier/admin. Bekreftelse via nettleserens `confirm()`
med eksplisitt, sterk ordlyd ("Kan ikke angres") -- samme mønster som
`round-detail.tsx` sin runde-sletting og `org-members.tsx` sin
medlem-fjerning, ingen ny bekreftelses-konvensjon oppfunnet.
**Bevisst IKKE bygget:** ingen statusbasert sperre (f.eks. "kan ikke
slette en `active`/`completed` turnering") -- status kan i dag settes
fritt av hvem som helst med ett klikk, usynkronisert med faktisk
datainnhold, så en statusbasert sperre ville vært en falsk trygghet.
Ekte barnesjekk (finnes det score) ble vurdert, men forkastet til fordel
for enkelhet -- eier/admin-sperren + den sterke `confirm()`-teksten ble
ansett tilstrekkelig for en bevisst, sjelden handling.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye
backend-tester (`tests/test_delete_tournament.py`, 111→115 totalt) --
begge formater med EKTE populerte data (lag-format: kamp+roster+
match_participant+hole_score; individuelt: runde+deltaker+round_
participant+round_hole), bekrefter RESTRICT-diamant-scenarioet faktisk
fungerer, pluss 403 for vanlig medlem og 404 for ikke-eksisterende
turnering. Full nettleser-scratch-verifisering (database+API+next dev)
med et ekte scratch-scenario med reell 2FA-fullføring (TOTP-hemmelighet
lest fra oppsettskjermen, kode beregnet med `pyotp`, siden org-eier/admin
krever obligatorisk 2FA ved innlogging) -- bekreftet søppelbøtte-ikonet
vises, `confirm()`-dialogen viser riktig tekst, og etter bekreftelse:
navigasjon til dashbord OG faktisk 0 rader igjen i `tournament`/`team`/
`team_roster`/`match`/`match_participant`/`hole_score`/`session`, mens
spillerne selv (2 stk) fortsatt fantes uendret. Scratch-stacken revet
ned.
**Rullet ut:** venter på bekreftelse.
---
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -12369,3 +12369,39 @@ Neste steg:
**Rullet ut:** venter på bekreftelse (ren frontend, ingen
migrasjon).
110. **Slett en hel turnering, permanent (ADR-086) — 2026-08-18.**
Bruker: "Jeg mangler mulighet til å slette HELE turneringen."
Bekreftet: genuint manglende -- ingen `DELETE`-endepunkt for selve
`tournament`-raden fantes, kun for under-ressurser (som i tillegg
nekter sletting så snart de har barn).
Nytt `DELETE /orgs/{id}/tournaments/{id}` (`app/routers/
tournaments.py`) -- strengere sperre enn resten av turnerings-
oppsettet (kun org-eier/admin, `is_org_admin`, ikke bare medlemskap
som alt annet her), siden dette er kategorisk mer ødeleggende.
**Kritisk skjemafunn:** to RESTRICT-FK-er sitter MIDT INNI
kaskade-grafen (`match_participant.team_roster_id`,
`tournament_round_participant.tournament_participant_id`) -- en
naiv enkelt-setnings-kaskade kunne feilt avhengig av Postgres sin
interne rekkefølge, KUN når det faktisk fantes ekte kamper/runder.
Løst med eksplisitt sletting i riktig rekkefølge i stedet for å
stole blindt på kaskaden. Spillerne selv (`player`) røres aldri --
kun turneringens koblinger til dem.
UI: søppelbøtte-ikon i header ved siden av statusvelgeren, i begge
turneringstyper (lag- og individuell-format), kun synlig for eier/
admin. `confirm()`-bekreftelse med sterk ordlyd, samme mønster som
runde-sletting/medlem-fjerning -- ingen ny bekreftelses-konvensjon.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye
backend-tester (111→115) -- begge formater med EKTE populerte data,
bekrefter RESTRICT-diamant-scenarioet faktisk fungerer, pluss 403/
404-grensetilfeller. Full nettleser-scratch-verifisering med ekte
2FA-fullføring (TOTP-kode beregnet med `pyotp` fra oppsettskjermens
hemmelighet) -- bekreftet knapp, `confirm()`-tekst, og at ALT
(turnering/lag/roster/kamper/match_participant/hole_score/økt)
faktisk ble borte mens spillerne selv besto uendret. Scratch-
stacken revet ned.
**Rullet ut:** venter på bekreftelse.

View file

@ -26,7 +26,7 @@ from ..email import send_session_result_email
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from .auth import _hash_secret
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
from ..team_authz import is_org_admin, is_org_member, user_is_team_captain, user_is_tournament_participant
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
from .. import storage
@ -246,6 +246,84 @@ async def update_tournament(
return _tournament_from_row(row)
@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}", status_code=204)
async def delete_tournament(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Sletter HELE turneringen -- permanent, ingen angre (ADR-086,
2026-08-18). Strengere sperre enn resten av denne fila (kun org-
medlemskap for oppsett) -- kun org-eier/admin, siden dette er
kategorisk mer ødeleggende enn noe annet i turneringsoppsettet
(fjerner ALL score-/matchdata permanent, ikke bare tomt oppsett).
Eksplisitt, RIKTIG REKKEFØLGE-sletting i stedet for å stole blindt
én kaskaderende `DELETE FROM tournament` -- to steder i skjemaet har
en RESTRICT-FK midt INNI kaskade-grafen (`match_participant.
team_roster_id` og `tournament_round_participant.tournament_
participant_id`, begge migrasjon 001/040), som kan avvise en
enkelt-setnings-kaskade avhengig av Postgres sin interne
kaskade-rekkefølge når det faktisk finnes ekte kamper/runder. Fjerner
derfor RESTRICT-blokkerende rader FØR det de peker slettes.
`org_connection` sin egen transaksjon gjør alt atomisk uansett --
feiler ett steg, ruller alt tilbake."""
async with org_connection(organization_id) as conn, translate_db_errors():
if not await is_org_admin(conn, organization_id, user.user_id):
raise app_error(
403,
"NOT_ORG_ADMIN",
"Kun organisasjonseier/administrator kan slette en turnering.",
)
exists = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id
)
if not exists:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
# Lag-format: fjern match_participant FØR team_roster forsvinner
# (RESTRICT-FK-en over) -- kaskaderer selv videre til hole_score.
await conn.execute(
"""
DELETE FROM match_participant
WHERE match_id IN (
SELECT m.id FROM match m
JOIN session s ON m.session_id = s.id
WHERE s.tournament_id = $1
)
""",
tournament_id,
)
await conn.execute(
"DELETE FROM match WHERE session_id IN (SELECT id FROM session WHERE tournament_id = $1)",
tournament_id,
)
await conn.execute("DELETE FROM session WHERE tournament_id = $1", tournament_id)
await conn.execute(
"DELETE FROM team_roster WHERE team_id IN (SELECT id FROM team WHERE tournament_id = $1)",
tournament_id,
)
await conn.execute("DELETE FROM team WHERE tournament_id = $1", tournament_id)
# Individuell-format: samme prinsipp -- tournament_round_participant
# FØR tournament_participant/tournament_round forsvinner.
await conn.execute(
"""
DELETE FROM tournament_round_participant
WHERE tournament_round_id IN (SELECT id FROM tournament_round WHERE tournament_id = $1)
OR tournament_participant_id IN (SELECT id FROM tournament_participant WHERE tournament_id = $1)
""",
tournament_id,
)
await conn.execute("DELETE FROM tournament_round WHERE tournament_id = $1", tournament_id)
await conn.execute("DELETE FROM tournament_participant WHERE tournament_id = $1", tournament_id)
# Resten (klasser, sponsorer, påmelding, feed/chat-meldinger,
# OoM-lenke) kaskaderer trygt fra selve tournament-raden -- ingen
# RESTRICT-FK-er igjen på dette punktet.
await conn.execute("DELETE FROM tournament WHERE id = $1", tournament_id)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
response_model=Tournament,

View file

@ -15,6 +15,7 @@ import type React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import dynamic from "next/dynamic"
import { useRouter } from "next/navigation"
import type { FlagMapEntry } from "@/components/flag-map-overview"
import {
ArrowLeft,
@ -342,6 +343,7 @@ export function IndividualTournamentDetail({
tournamentId: string
tournamentName: string
}) {
const router = useRouter()
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
const [participants, setParticipants] = useState<ApiParticipant[]>([])
const [rounds, setRounds] = useState<ApiRound[]>([])
@ -350,9 +352,51 @@ export function IndividualTournamentDetail({
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup")
// Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- samme
// sperre/begrunnelse som tournament-detail.tsx (lagformatet).
const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null)
const [deleting, setDeleting] = useState(false)
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => {
if (cancelled || !me) return
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [organizationId])
const isOrgAdmin = myRole === "owner" || myRole === "admin"
async function deleteTournament() {
if (
!confirm(
`Slette "${tournamentName}" permanent? Dette fjerner ALT -- deltakere, alle runder og all scoring. Kan ikke angres.`,
)
) {
return
}
setDeleting(true)
setError(null)
try {
const res = await fetch(base, { method: "DELETE", credentials: "include" })
if (!res.ok && res.status !== 204) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.")
}
router.replace("/dashboard")
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.")
setDeleting(false)
}
}
useEffect(() => {
let cancelled = false
async function load() {
@ -692,6 +736,17 @@ export function IndividualTournamentDetail({
<div className="flex flex-wrap items-center gap-2 pl-13">
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
<JoinCodeChip code={tournament.join_code} />
{isOrgAdmin && (
<button
type="button"
onClick={() => void deleteTournament()}
disabled={deleting}
aria-label="Slett turnering"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
>
<Trash2 aria-hidden="true" className="size-5" />
</button>
)}
</div>
</div>

View file

@ -3,6 +3,7 @@
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
ArrowLeftRight,
@ -94,6 +95,7 @@ export function TournamentDetail({
tournamentId: string
tournamentName: string
}) {
const router = useRouter()
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
const [pool, setPool] = useState<ApiPlayer[]>([])
const [joinCode, setJoinCode] = useState<string | null>(null)
@ -101,9 +103,56 @@ export function TournamentDetail({
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- strengere
// sperre enn resten av denne siden, som kun krever org-medlemskap. Håndheves
// reelt server-side; dette skjuler bare knappen for noen den uansett ville
// fått 403 på.
const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null)
const [deleting, setDeleting] = useState(false)
const [showImport, setShowImport] = useState(false)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => {
if (cancelled || !me) return
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [organizationId])
const isOrgAdmin = myRole === "owner" || myRole === "admin"
async function deleteTournament() {
if (
!confirm(
`Slette "${tournamentName}" permanent? Dette fjerner ALT -- lag, spillere sin rostring, alle kamper og all scoring. Kan ikke angres.`,
)
) {
return
}
setDeleting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.")
}
router.replace("/dashboard")
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.")
setDeleting(false)
}
}
useEffect(() => {
let cancelled = false
async function load() {
@ -443,6 +492,17 @@ export function TournamentDetail({
</div>
{status && <TournamentStatusPicker status={status} onChange={updateStatus} />}
{joinCode && <JoinCodeChip code={joinCode} />}
{isOrgAdmin && (
<button
type="button"
onClick={() => void deleteTournament()}
disabled={deleting}
aria-label="Slett turnering"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
>
<Trash2 aria-hidden="true" className="size-5" />
</button>
)}
</div>
<nav

View file

@ -0,0 +1,142 @@
"""
Full sletting av en hel turnering (ADR-086) -- kritisk å teste med EKTE
populert data i begge formater, ikke bare en tom turnering, siden skjemaet
har to RESTRICT-FK-er MIDT INNI kaskade-grafen (match_participant.
team_roster_id, tournament_round_participant.tournament_participant_id) som
kan avvise en naiv enkelt-setnings-kaskade. Se delete_tournament() sin egen
kommentar i app/routers/tournaments.py for full begrunnelse.
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.tournaments import delete_tournament
from tests.conftest import (
add_membership,
create_course,
create_match,
create_match_participant,
create_org,
create_org_hole,
create_player,
create_session,
create_team,
create_team_roster,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
from app import db as app_db
async def test_delete_team_tournament_with_real_matches_and_scores(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id = await create_tournament(org_id)
course_id = await create_course(org_id)
tee_id = await create_tee(org_id, course_id)
session_id = await create_session(org_id, tournament_id, course_id)
team_a = await create_team(org_id, tournament_id)
team_b = await create_team(org_id, tournament_id)
match_id = await create_match(org_id, session_id, team_a, team_b)
player_a = await create_player(org_id)
player_b = await create_player(org_id)
roster_a = await create_team_roster(org_id, team_a, player_a)
roster_b = await create_team_roster(org_id, team_b, player_b)
mp_a = await create_match_participant(org_id, match_id, "a", roster_a, tee_id)
mp_b = await create_match_participant(org_id, match_id, "b", roster_b, tee_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, "
"hole_number, gross_strokes) VALUES ($1, $2, 'a', $3, 1, 4)",
org_id, match_id, mp_a,
)
await conn.execute(
"INSERT INTO hole_score (organization_id, match_id, team_side, match_participant_id, "
"hole_number, gross_strokes) VALUES ($1, $2, 'b', $3, 1, 5)",
org_id, match_id, mp_b,
)
user = CurrentUser(user_id=owner_id)
await delete_tournament(tournament_id, organization_id=org_id, user=user)
async with app_db.org_connection(org_id) as conn:
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is False
assert await conn.fetchval("SELECT count(*) FROM session WHERE tournament_id = $1", tournament_id) == 0
assert await conn.fetchval("SELECT count(*) FROM match WHERE id = $1", match_id) == 0
assert await conn.fetchval("SELECT count(*) FROM match_participant WHERE match_id = $1", match_id) == 0
assert await conn.fetchval("SELECT count(*) FROM hole_score WHERE match_id = $1", match_id) == 0
assert await conn.fetchval("SELECT count(*) FROM team_roster WHERE team_id = $1", team_a) == 0
assert await conn.fetchval("SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id) == 0
# Spillerne selv (org-scopet spillerpool) skal IKKE slettes -- kun
# turneringens egne koblinger til dem.
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM player WHERE id = $1)", player_a) is True
async def test_delete_individual_tournament_with_real_rounds_and_scores(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id = await create_tournament(org_id)
course_id = await create_course(org_id)
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n)
tee_id = await create_tee(org_id, course_id)
round_id = await create_tournament_round(org_id, tournament_id, course_id)
player_id = await create_player(org_id)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"INSERT INTO tournament_round_hole (organization_id, tournament_round_participant_id, "
"hole_number, gross_strokes) VALUES ($1, $2, 1, 4)",
org_id, rp_id,
)
user = CurrentUser(user_id=owner_id)
await delete_tournament(tournament_id, organization_id=org_id, user=user)
async with app_db.org_connection(org_id) as conn:
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is False
assert await conn.fetchval("SELECT count(*) FROM tournament_round WHERE tournament_id = $1", tournament_id) == 0
assert await conn.fetchval("SELECT count(*) FROM tournament_participant WHERE tournament_id = $1", tournament_id) == 0
assert await conn.fetchval(
"SELECT count(*) FROM tournament_round_participant WHERE tournament_round_id = $1", round_id
) == 0
assert await conn.fetchval(
"SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id
) == 0
async def test_delete_requires_org_admin_not_just_member(pool):
org_id = await create_org()
member_id = await create_user()
await add_membership(org_id, member_id, role="member")
tournament_id = await create_tournament(org_id)
user = CurrentUser(user_id=member_id)
with pytest.raises(HTTPException) as exc_info:
await delete_tournament(tournament_id, organization_id=org_id, user=user)
assert exc_info.value.status_code == 403
async with app_db.org_connection(org_id) as conn:
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id) is True
async def test_delete_nonexistent_tournament_is_404(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
user = CurrentUser(user_id=owner_id)
with pytest.raises(HTTPException) as exc_info:
await delete_tournament("00000000-0000-0000-0000-000000000000", organization_id=org_id, user=user)
assert exc_info.value.status_code == 404