Optimistisk versjonssjekk for org-turneringers match-scoring
All checks were successful
Backend-tester / test (push) Successful in 21s

Fortsetter robusthetslinjen fra ADR-057: hole_score/match_hole_result
(scoring.py) hadde ingen samtidighetsvern, ren siste-skriving-vinner --
den siste dokumenterte, kjente svakheten fra den opprinnelige
"harde kjerner"-vurderingen. Samme version-kolonne-mønster, tilpasset
UPSERT (INSERT ... ON CONFLICT DO UPDATE ... WHERE) i stedet for ren
UPDATE. Offline-kø-kjedingen generalisert til å nøkle på ressurs
(resourceKey), ikke bare URL -- nødvendig siden disse to endepunktene
deler samme URL for alle hull i en match. Se ADR-060/CHANGELOG punkt 69.
23/23 backend-tester grønne, tsc rent. Migrasjon 063 kjørt mot ekte
teecup_db, teecup_api/teecup_frontend rebygget, ingen konsollfeil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-10 23:19:29 +02:00
parent b65491afc8
commit 5c2455c1fe
8 changed files with 559 additions and 25 deletions

View file

@ -0,0 +1,4 @@
-- ADR-060: samme optimistiske versjonssjekk som round_hole (migrasjon 062,
-- ADR-057) fikk, nå for org-turneringenes match-scoring.
ALTER TABLE hole_score ADD COLUMN version integer NOT NULL DEFAULT 1;
ALTER TABLE match_hole_result ADD COLUMN version integer NOT NULL DEFAULT 1;

View file

@ -5694,6 +5694,76 @@ kjørte, viste "Success" i Forgejo sitt UI (grønn hake, samme run som
sto "Waiting" rett før runneren startet). `teecup_db`s rolleoppsett og
fravær av gjenglemte scratch-databaser bekreftet uendret etter kjøring.
---
## ADR-060: Optimistisk versjonssjekk for org-turneringers match-scoring — 2026-08-10
Fortsettelse av robusthetslinjen fra ADR-057: bruker ba eksplisitt om å
tette det siste kjente, dokumenterte hullet — org-turneringenes
match-scoring (`hole_score`/`match_hole_result`,
`app/routers/scoring.py`, `session-scorecard.tsx`) hadde INGEN
samtidighetsvern, ren `INSERT ... ON CONFLICT DO UPDATE` siste-skriving-
vinner, dokumentert som bevisst utenfor omfang i både ADR-057 og
ADR-058.
**Beslutning A — samme `version`-kolonne-mønster, men UPSERT i stedet
for UPDATE.** Migrasjon 063 legger `version integer NOT NULL DEFAULT 1`
til BÅDE `hole_score` og `match_hole_result` (to separate tabeller, to
separate scoring-modi — `stroke` vs. `hole_result` — se
`session.scoring_mode`). Ulikt round_hole (som alltid har en
forhåndseksisterende rad å oppdatere) skriver disse to endepunktene
via `INSERT ... ON CONFLICT DO UPDATE`, siden FØRSTE registrering av et
hull ikke har noen eksisterende rad. Postgres sin
`DO UPDATE SET ... WHERE <betingelse>` løser dette elegant: betingelsen
sjekkes KUN på selve UPDATE-grenen (konflikt-tilfellet) og ignoreres
fullstendig på ren INSERT — en helt ny rad tar alltid INSERT-veien og
bryr seg aldri om `expected_version`, uansett hva klienten sendte.
Konsekvens: `row is None` etter denne UPSERT-en betyr ALLTID en ekte
versjonskonflikt, ALDRI "finnes ikke" — INSERT-grenen dekker
"finnes ikke"-tilfellet transparent. Enklere enn round_hole/
`update_side_hole`, som trengte en egen eksistenssjekk for å skille de
to.
**Beslutning B — offline-kø-kjedingen (`offline-queue.ts`) måtte
generaliseres, ikke bare gjenbrukes.** Reelt funn UNDER design, før
noe ble bygget: ADR-057s versjonskjeding nøklet på `url` alene, riktig
for round_hole (én URL per hull). Men `/hole-scores`/`/hole-results`
bruker SAMME URL for ALLE hull i en match — uten en mer presis nøkkel
ville versjonen returnert fra hull 3 sin skriving blitt brukt som
"forventet versjon" for en påfølgende, urelatert skriving til hull 7 i
samme flush-runde, verre enn ingen kjeding i det hele tatt. Løst med et
nytt, valgfritt `resourceKey`-felt på `QueueEntry`
(default = `url`, så round_hole sin eksisterende bruk er 100 %
bakoverkompatibel, uendret oppførsel) — `session-scorecard.tsx` sender
nå en egen nøkkel per hull+side+enhet (`strokeKey`/`resultKey`).
**Beslutning C — autorisasjon i testene, ikke i produksjonskoden.**
`submit_hole_score`/`submit_hole_result` krever at brukeren enten ER en
faktisk match-deltaker (via `player`/`team_roster`/`match_participant`-
kjeden) ELLER org-eier/admin (`is_org_admin`-fallback,
`team_authz.py`). Testene bruker bevisst org-eier-fallbacken for de
fleste tilfellene (langt billigere fixture-oppsett), men bygger den
FULLE deltaker-kjeden for individuell-ball-testen spesifikt, siden
`match_participant_id`-oppslaget i `submit_hole_score` er uavhengig av
org-admin-status og krever en ekte rad.
**Verifisert:** 6 nye pytest-integrasjonstester
(`tests/test_scoring_concurrency.py`) — delt-ball OG individuell-ball-
grenen i `submit_hole_score` hver for seg, `submit_hole_result`, alle
med vellykket skrivning/versjonsøkning, 409 STALE_VERSION ved konflikt,
og bekreftet at en avvist skrivning faktisk IKKE ble lagret. Alle 23
backend-tester (17 fra før + 6 nye) grønne i samme kjøring via CI-
runneren fra ADR-059. `tsc --noEmit` rent. Ingen deploy i selve
skriverunden — venter på eksplisitt bekreftelse per CLAUDE.md før
migrasjon 063 kjøres mot ekte `teecup_db`.
**Bevisst utenfor omfang:** ingen browser-basert to-enhets-verifisering
denne runden (ulikt ADR-057) — pytest-dekningen ble vurdert som
tilstrekkelig gitt at testene kaller de faktiske router-funksjonene
direkte, og frontend-siden (`session-scorecard.tsx`) følger nøyaktig
samme, allerede browser-verifiserte mønster som `round-detail.tsx` fikk
i forrige runde.
Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på

View file

@ -10324,3 +10324,34 @@ Neste steg:
Forgejo sitt UI (grønn hake, samme kø-oppføring som nettopp sto
"Waiting"). `teecup_db`s rolleoppsett og fravær av gjenglemte
scratch-databaser bekreftet uendret etter kjøring.
69. **Optimistisk versjonssjekk for org-turneringers match-scoring —
2026-08-10, se ADR-060.** Fortsettelse av robusthetslinjen: den
siste dokumenterte, kjente svakheten (ADR-057/058: org-
turneringenes match-scoring hadde ingen samtidighetsvern, ren
siste-skriving-vinner) tettet på brukerens eksplisitte forespørsel.
Migrasjon 063: `version`-kolonne på BÅDE `hole_score` og
`match_hole_result` (to separate tabeller/scoring-modi). Ulikt
round_hole er disse UPSERT (`INSERT ... ON CONFLICT DO UPDATE`),
ikke ren UPDATE — løst med Postgres sin `DO UPDATE ... WHERE`, som
kun evalueres på selve konflikt-grenen. Konsekvens: enklere enn
round_hole — en avvist skrivning er ALLTID en versjonskonflikt,
aldri en "finnes ikke"-tvetydighet.
Reelt funn under design: offline-kø-kjedingen fra punkt 66 nøklet
på URL alene, som IKKE er entydig for `/hole-scores`/`/hole-
results` (samme URL for alle hull i en match). Generalisert med et
nytt, valgfritt `resourceKey`-felt på kø-oppføringen (default url,
100 % bakoverkompatibelt med round_hole sin bruk).
`submit_hole_score`/`submit_hole_result` i `app/routers/scoring.py`,
`session-scorecard.tsx`, `offline-queue.ts`. 6 nye pytest-tester
(`tests/test_scoring_concurrency.py`) — delt-ball og individuell-
ball-grenen hver for seg, begge endepunktene, vellykket skrivning +
409-konflikt + bekreftet at avvist skrivning ikke lagres. Alle 23
backend-tester grønne (kjørt via CI-runneren fra punkt 68). `tsc
--noEmit` rent.
**Ikke rullet ut ennå** — venter på eksplisitt bekreftelse før
migrasjon 063 kjøres mot ekte `teecup_db`, per CLAUDE.md.

View file

@ -471,6 +471,12 @@ class HoleScoreCreate(BaseModel):
# funksjon som frittstående runder fikk 2026-07-25). KUN meningsfullt for
# delt-ball-hull (match_participant_id er None) -- helt valgfritt.
selected_participant_id: str | None = None
# Optimistisk versjonssjekk (ADR-060, samme mønster som round_hole/
# ADR-057). None = hopp over sjekken -- eneste rimelige verdi for et
# hull som ikke er registrert fra før (klienten kan ikke kjenne en
# versjon som ikke finnes ennå), og fortsatt bakoverkompatibelt med
# eldre klienter som ikke sender feltet.
expected_version: int | None = None
class HoleScoreOut(BaseModel):
@ -479,6 +485,7 @@ class HoleScoreOut(BaseModel):
match_participant_id: str | None
gross_strokes: int
selected_participant_id: str | None = None
version: int
@router.post(
@ -579,6 +586,14 @@ async def submit_hole_score(
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
)
# Versjonssjekken (ADR-060) trengs KUN på DO UPDATE-grenen -- en helt
# ny rad (første registrering av dette hullet) tar alltid den rene
# INSERT-veien og bryr seg aldri om expected_version, uansett hva
# klienten sendte (det finnes ingen tidligere versjon å sjekke mot).
# Et `row is None`-resultat herfra betyr derfor ALLTID en ekte
# versjonskonflikt -- aldri "finnes ikke" (INSERT-grenen dekker det
# tilfellet transparent), ulikt round_hole som trengte en egen
# eksistenssjekk for å skille de to.
if body.match_participant_id is not None:
row = await conn.fetchrow(
"""
@ -586,10 +601,12 @@ async def submit_hole_score(
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now()
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now(),
version = hole_score.version + 1
WHERE $7::int IS NULL OR hole_score.version = $7::int
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id
selected_participant_id::text AS selected_participant_id, version
""",
organization_id,
match_id,
@ -597,6 +614,7 @@ async def submit_hole_score(
body.match_participant_id,
body.hole_number,
body.gross_strokes,
body.expected_version,
)
else:
row = await conn.fetchrow(
@ -608,10 +626,11 @@ async def submit_hole_score(
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
selected_participant_id = EXCLUDED.selected_participant_id,
updated_at = now()
updated_at = now(), version = hole_score.version + 1
WHERE $7::int IS NULL OR hole_score.version = $7::int
RETURNING hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id
selected_participant_id::text AS selected_participant_id, version
""",
organization_id,
match_id,
@ -619,6 +638,12 @@ async def submit_hole_score(
body.hole_number,
body.gross_strokes,
body.selected_participant_id,
body.expected_version,
)
if row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await recompute_and_cache_match_state(conn, match_id)
@ -628,11 +653,14 @@ async def submit_hole_score(
class HoleResultCreate(BaseModel):
hole_number: int = Field(ge=1, le=18)
winning_side: str | None = Field(default=None, pattern="^[ab]$")
# Se HoleScoreCreate.expected_version -- samme begrunnelse, samme mønster.
expected_version: int | None = None
class HoleResultOut(BaseModel):
hole_number: int
winning_side: str | None
version: int
@router.post(
@ -686,14 +714,22 @@ async def submit_hole_result(
INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, hole_number)
DO UPDATE SET winning_side = EXCLUDED.winning_side
RETURNING hole_number, winning_side::text AS winning_side
DO UPDATE SET winning_side = EXCLUDED.winning_side,
version = match_hole_result.version + 1
WHERE $5::int IS NULL OR match_hole_result.version = $5::int
RETURNING hole_number, winning_side::text AS winning_side, version
""",
organization_id,
match_id,
body.hole_number,
body.winning_side,
body.expected_version,
)
if row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await recompute_and_cache_match_state(conn, match_id)
return HoleResultOut(**dict(row))
@ -750,7 +786,7 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
"""
SELECT hole_number, team_side::text AS team_side,
match_participant_id::text AS match_participant_id, gross_strokes,
selected_participant_id::text AS selected_participant_id
selected_participant_id::text AS selected_participant_id, version
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
""",
match_id,
@ -759,7 +795,7 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
else:
rows = await conn.fetch(
"""
SELECT hole_number, winning_side::text AS winning_side
SELECT hole_number, winning_side::text AS winning_side, version
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
""",
match_id,

View file

@ -97,9 +97,11 @@ type ApiHoleScore = {
// funksjon som frittstående runder fikk 2026-07-25). Kun satt for
// delt-ball-rader (match_participant_id null).
selected_participant_id: string | null
// Optimistisk versjonssjekk (ADR-060). Alltid satt av serveren.
version: number
}
type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null }
type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null; version: number }
type ApiScorecard = {
match_id: string
@ -123,11 +125,20 @@ type Unit = {
}
// Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per
// hull+side+enhet, samme identitet som backend bruker for upsert.
// hull+side+enhet, samme identitet som backend bruker for upsert. Brukes
// OGSÅ som offline-kø-ens resourceKey (ADR-060) -- /hole-scores har samme
// URL for alle hull i matchen, så uten en egen ressursnøkkel ville
// versjons-kjedingen i flushQueue blandet sammen ulike hull.
function strokeKey(holeNumber: number, unit: Unit): string {
return `${holeNumber}:${unit.side}:${unit.matchParticipantId ?? ""}`
}
// Samme begrunnelse som strokeKey, for /hole-results (som ikke har
// side/enhet -- kun ett resultat per hull).
function resultKey(holeNumber: number): string {
return `result:${holeNumber}`
}
function playedHoleNumbers(holeConfig: HoleConfig): number[] {
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
@ -245,16 +256,21 @@ export function SessionScorecard({
hole_number: holeNumber,
gross_strokes: grossStrokes,
selected_participant_id: selectedParticipantId,
expected_version: strokeVersionFor(holeNumber, unit),
}
await enqueueWrite({ url, method: "POST", body, matchId })
await enqueueWrite({ url, method: "POST", body, matchId, resourceKey: strokeKey(holeNumber, unit) })
setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes))
setPendingCount((c) => c + 1)
}
async function queueHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
const url = `/orgs/${organizationId}/matches/${matchId}/hole-results`
const body = { hole_number: holeNumber, winning_side: winningSide }
await enqueueWrite({ url, method: "POST", body, matchId })
const body = {
hole_number: holeNumber,
winning_side: winningSide,
expected_version: resultVersionFor(holeNumber),
}
await enqueueWrite({ url, method: "POST", body, matchId, resourceKey: resultKey(holeNumber) })
setPendingResults((prev) => new Map(prev).set(holeNumber, winningSide))
setPendingCount((c) => c + 1)
}
@ -363,6 +379,21 @@ export function SessionScorecard({
return entry?.selected_participant_id ?? null
}
// Sist kjente versjon for et hull (ADR-060) -- undefined/null for et
// hull som ikke er registrert fra før, som er riktig: serveren bryr seg
// ikke om expected_version på en helt ny rad (se scoring.py).
function strokeVersionFor(holeNumber: number, unit: Unit): number | null {
const entry = (scorecard?.stroke_entries ?? []).find(
(e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId,
)
return entry?.version ?? null
}
function resultVersionFor(holeNumber: number): number | null {
const entry = (scorecard?.hole_result_entries ?? []).find((e) => e.hole_number === holeNumber)
return entry?.version ?? null
}
// `selectedParticipantId` utelatt = behold gjeldende valg uendret (brukt
// av selve slagregistreringen); eksplisitt sendt (inkl. `null`) = et nytt
// utslags-valg (brukt av "hvem sitt utslag ble brukt"-knappene under).
@ -391,6 +422,7 @@ export function SessionScorecard({
hole_number: holeNumber,
gross_strokes: grossStrokes,
selected_participant_id: resolvedSelection,
expected_version: strokeVersionFor(holeNumber, unit),
}),
})
} catch {
@ -400,6 +432,14 @@ export function SessionScorecard({
return
}
if (!res.ok) {
if (res.status === 409) {
const body = await res.json().catch(() => null)
if (body?.detail?.code === "STALE_VERSION") {
setError(body.detail.message ?? "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.")
await refetchScorecard()
return
}
}
setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.")
return
}
@ -443,13 +483,25 @@ export function SessionScorecard({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }),
body: JSON.stringify({
hole_number: holeNumber,
winning_side: winningSide,
expected_version: resultVersionFor(holeNumber),
}),
})
} catch {
await queueHoleResult(holeNumber, winningSide)
return
}
if (!res.ok) {
if (res.status === 409) {
const body = await res.json().catch(() => null)
if (body?.detail?.code === "STALE_VERSION") {
setError(body.detail.message ?? "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.")
await refetchScorecard()
return
}
}
setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.")
return
}

View file

@ -18,6 +18,14 @@ export type QueueEntry = {
body: unknown
matchId: string
createdAt: number
// Identifiserer HVILKEN rad på serveren denne skrivingen gjelder --
// default url (uendret oppførsel for kallere med én rad per URL, f.eks.
// round_hole). NØDVENDIG å sette eksplisitt når flere ulike rader deler
// samme URL (f.eks. scoring.py sin /hole-scores, som tar imot ALLE hull
// i en match på nøyaktig samme endepunkt) -- ellers ville versjons-
// kjedingen i flushQueue under blandet sammen versjonsnummer mellom
// urelaterte rader (se ADR-060).
resourceKey?: string
}
function openDb(): Promise<IDBDatabase> {
@ -84,23 +92,32 @@ export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
const entries = await listQueue(matchId)
const outcomes: FlushOutcome[] = []
// Kjeder expected_version fremover for flere køede skrivinger til SAMME
// URL i én flush-runde (2026-08-10, ADR-057 versjonssjekk). Uten dette
// ville en spillers EGNE påfølgende offline-redigeringer av samme hull
// avvist hverandre som falske konflikter: kun den FØRSTE køede
// oppføringen har en expected_version som fortsatt stemmer med serveren
// -- lokal state (og dermed enqueue-tidspunktets versjon) oppdateres
// aldri mellom to sekvensielle avspillinger i samme batch, så oppføring
// 2 ville ellers sendt den samme (nå utdaterte) versjonen som 1.
const latestVersionByUrl = new Map<string, number>()
// RESSURS i én flush-runde (2026-08-10, ADR-057 versjonssjekk; nøkkel
// generalisert til resourceKey 2026-08-10, ADR-060). Uten dette ville en
// spillers EGNE påfølgende offline-redigeringer av samme rad avvist
// hverandre som falske konflikter: kun den FØRSTE køede oppføringen har
// en expected_version som fortsatt stemmer med serveren -- lokal state
// (og dermed enqueue-tidspunktets versjon) oppdateres aldri mellom to
// sekvensielle avspillinger i samme batch, så oppføring 2 ville ellers
// sendt den samme (nå utdaterte) versjonen som 1.
//
// NØKKELEN er `resourceKey ?? url`, IKKE bare url -- for round_hole er
// url alene entydig (én URL per hull), men scoring.py sine /hole-scores/
// /hole-results tar imot ALLE hull i en match på nøyaktig samme URL. Uten
// en mer presis nøkkel ville versjonen fra hull 3 sin skriving blitt
// brukt som "forventet versjon" for en påfølgende, helt urelatert
// skriving til hull 7 -- verre enn ingen kjeding i det hele tatt.
const latestVersionByKey = new Map<string, number>()
for (const entry of entries) {
const key = entry.resourceKey ?? entry.url
let body: unknown = entry.body
if (
body !== null &&
typeof body === "object" &&
"expected_version" in body &&
latestVersionByUrl.has(entry.url)
latestVersionByKey.has(key)
) {
body = { ...body, expected_version: latestVersionByUrl.get(entry.url) }
body = { ...body, expected_version: latestVersionByKey.get(key) }
}
let res: Response
try {
@ -116,7 +133,7 @@ export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
if (res.ok) {
const updated: { version?: number } | null = await res.json().catch(() => null)
if (updated && typeof updated.version === "number") {
latestVersionByUrl.set(entry.url, updated.version)
latestVersionByKey.set(key, updated.version)
}
await removeFromQueue(entry.id)
outcomes.push({ entry, ok: true })

View file

@ -118,3 +118,124 @@ async def create_hole(conn, participant_id: str, hole_number: int, par: int = 4,
hole_id, participant_id, hole_number, par, stroke_index, version,
)
return hole_id
# ---------------------------------------------------------------------------
# Org-turnering-kjeden (organisasjon -> turnering -> lag/bane/tee -> økt ->
# match[-deltaker]) -- brukt av test_scoring_concurrency.py. Alle disse
# tabellene er org-scopet (RLS), derfor org_connection(organization_id) i
# hver hjelper i stedet for en delt `conn`-parameter (samme mønster som
# create_org/add_membership over).
# ---------------------------------------------------------------------------
async def create_tournament(organization_id: str, **overrides) -> str:
tournament_id = overrides.get("id", new_id())
name = overrides.get("name", f"Testturnering {tournament_id[:8]}")
join_code = overrides.get("join_code", tournament_id[:8])
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"INSERT INTO tournament (id, organization_id, name, join_code) VALUES ($1, $2, $3, $4)",
tournament_id, organization_id, name, join_code,
)
return tournament_id
async def create_team(organization_id: str, tournament_id: str, **overrides) -> str:
team_id = overrides.get("id", new_id())
name = overrides.get("name", f"Testlag {team_id[:8]}")
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"INSERT INTO team (id, organization_id, tournament_id, name) VALUES ($1, $2, $3, $4)",
team_id, organization_id, tournament_id, name,
)
return team_id
async def create_course(organization_id: str, **overrides) -> str:
course_id = overrides.get("id", new_id())
name = overrides.get("name", f"Testbanen {course_id[:8]}")
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"INSERT INTO course (id, organization_id, name) VALUES ($1, $2, $3)",
course_id, organization_id, name,
)
return course_id
async def create_tee(organization_id: str, course_id: str, **overrides) -> str:
tee_id = overrides.get("id", new_id())
name = overrides.get("name", "Gul")
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"INSERT INTO tee (id, organization_id, course_id, name) VALUES ($1, $2, $3, $4)",
tee_id, organization_id, course_id, name,
)
return tee_id
async def create_session(organization_id: str, tournament_id: str, course_id: str, **overrides) -> str:
session_id = overrides.get("id", new_id())
sequence = overrides.get("sequence", 1)
format_ = overrides.get("format", "singles")
scoring_mode = overrides.get("scoring_mode", "stroke")
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"""
INSERT INTO session (id, organization_id, tournament_id, sequence, format,
course_id, scoring_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
session_id, organization_id, tournament_id, sequence, format_, course_id, scoring_mode,
)
return session_id
async def create_match(organization_id: str, session_id: str, team_a_id: str, team_b_id: str,
**overrides) -> str:
match_id = overrides.get("id", new_id())
sequence = overrides.get("sequence", 1)
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"""
INSERT INTO match (id, organization_id, session_id, sequence, team_a_id, team_b_id)
VALUES ($1, $2, $3, $4, $5, $6)
""",
match_id, organization_id, session_id, sequence, team_a_id, team_b_id,
)
return match_id
async def create_player(organization_id: str, **overrides) -> str:
player_id = overrides.get("id", new_id())
user_id = overrides.get("user_id")
display_name = overrides.get("display_name", f"Testspiller {player_id[:8]}")
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"INSERT INTO player (id, organization_id, user_id, display_name) VALUES ($1, $2, $3, $4)",
player_id, organization_id, user_id, display_name,
)
return player_id
async def create_team_roster(organization_id: str, team_id: str, player_id: str, **overrides) -> str:
roster_id = overrides.get("id", new_id())
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"INSERT INTO team_roster (id, organization_id, team_id, player_id) VALUES ($1, $2, $3, $4)",
roster_id, organization_id, team_id, player_id,
)
return roster_id
async def create_match_participant(organization_id: str, match_id: str, team_side: str,
team_roster_id: str, tee_id: str, **overrides) -> str:
participant_id = overrides.get("id", new_id())
async with app_db.org_connection(organization_id) as conn:
await conn.execute(
"""
INSERT INTO match_participant (id, organization_id, match_id, team_side, team_roster_id, tee_id)
VALUES ($1, $2, $3, $4, $5, $6)
""",
participant_id, organization_id, match_id, team_side, team_roster_id, tee_id,
)
return participant_id

View file

@ -0,0 +1,203 @@
"""
ADR-060: samme optimistiske versjonssjekk som round_hole (ADR-057) fikk,
for org-turneringenes match-scoring (hole_score/match_hole_result,
app/routers/scoring.py). Kaller de faktiske router-funksjonene direkte --
samme kodesti som produksjon, ikke en SQL-gjenimplementering.
Autorisasjon: testbrukeren er satt som org-EIER (organization_membership
role='owner'), som tilfredsstiller user_is_match_participant sin
is_org_admin-fallback (team_authz.py) uten å måtte bygge ut hele
player/team_roster/match_participant-kjeden for HVER test -- kun
test_hole_score_individual_ball_participant_path bygger den fulle kjeden,
siden DEN grenen i submit_hole_score krever en ekte match_participant-rad
å slå opp uavhengig av org-admin-status.
"""
import pytest
from fastapi import HTTPException
from app import db as app_db
from app.auth import CurrentUser
from app.routers.scoring import (
HoleResultCreate,
HoleScoreCreate,
submit_hole_result,
submit_hole_score,
)
from tests.conftest import (
add_membership,
create_course,
create_match,
create_match_participant,
create_org,
create_player,
create_session,
create_team,
create_team_roster,
create_tee,
create_tournament,
create_user,
)
async def _setup_match(scoring_mode: str = "stroke", format_: str = "foursome"):
org_id = await create_org()
user_id = await create_user()
await add_membership(org_id, user_id, role="owner")
tournament_id = await create_tournament(org_id)
team_a = await create_team(org_id, tournament_id)
team_b = await create_team(org_id, tournament_id)
course_id = await create_course(org_id)
session_id = await create_session(
org_id, tournament_id, course_id, format=format_, scoring_mode=scoring_mode
)
match_id = await create_match(org_id, session_id, team_a, team_b)
return org_id, user_id, match_id, team_a, team_b
async def test_hole_score_shared_ball_succeeds_and_increments_version(pool):
org_id, user_id, match_id, _, _ = await _setup_match()
user = CurrentUser(user_id=user_id)
first = await submit_hole_score(
match_id,
HoleScoreCreate(team_side="a", hole_number=1, gross_strokes=5, expected_version=None),
org_id,
user=user,
)
assert first.version == 1
assert first.gross_strokes == 5
second = await submit_hole_score(
match_id,
HoleScoreCreate(team_side="a", hole_number=1, gross_strokes=4, expected_version=1),
org_id,
user=user,
)
assert second.version == 2
assert second.gross_strokes == 4
async def test_hole_score_stale_version_is_409(pool):
org_id, user_id, match_id, _, _ = await _setup_match()
user = CurrentUser(user_id=user_id)
await submit_hole_score(
match_id,
HoleScoreCreate(team_side="a", hole_number=1, gross_strokes=5, expected_version=None),
org_id,
user=user,
)
# "Enhet 2" hadde OGSÅ lest versjon 1 (før dette skjedde), og prøver nå
# å skrive med en versjon som allerede er forbigått.
with pytest.raises(HTTPException) as exc_info:
await submit_hole_score(
match_id,
HoleScoreCreate(team_side="a", hole_number=1, gross_strokes=6, expected_version=99),
org_id,
user=user,
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail["code"] == "STALE_VERSION"
# Og bekreft at den avviste skrivingen faktisk IKKE ble lagret.
async with app_db.org_connection(org_id) as conn:
row = await conn.fetchrow(
"SELECT gross_strokes, version FROM hole_score WHERE match_id = $1 AND hole_number = 1", match_id
)
assert row["gross_strokes"] == 5
assert row["version"] == 1
async def test_hole_score_expected_version_none_bypasses_check(pool):
org_id, user_id, match_id, _, _ = await _setup_match()
user = CurrentUser(user_id=user_id)
await submit_hole_score(
match_id,
HoleScoreCreate(team_side="a", hole_number=1, gross_strokes=5, expected_version=None),
org_id,
user=user,
)
out = await submit_hole_score(
match_id,
HoleScoreCreate(team_side="a", hole_number=1, gross_strokes=7, expected_version=None),
org_id,
user=user,
)
assert out.gross_strokes == 7
assert out.version == 2
async def test_hole_score_individual_ball_participant_path(pool):
"""Den ANDRE ON CONFLICT-grenen i submit_hole_score (match_participant_id
satt) -- egen kode, egen WHERE-versjonssjekk, verdt å dekke separat fra
delt-ball-testene over. Krever en ekte match_participant-rad (kylling-
og-egg: user_is_match_participant sin participant-sjekk er uavhengig av
org-admin-fallbacken for AKKURAT denne oppslags-grenen i
submit_hole_score, se linje ~534 i scoring.py)."""
org_id, user_id, match_id, team_a, _ = await _setup_match(format_="singles")
user = CurrentUser(user_id=user_id)
course_id = await create_course(org_id)
tee_id = await create_tee(org_id, course_id)
player_id = await create_player(org_id, user_id=user_id)
roster_id = await create_team_roster(org_id, team_a, player_id)
participant_id = await create_match_participant(org_id, match_id, "a", roster_id, tee_id)
first = await submit_hole_score(
match_id,
HoleScoreCreate(
team_side="a", match_participant_id=participant_id, hole_number=1,
gross_strokes=4, expected_version=None,
),
org_id,
user=user,
)
assert first.version == 1
with pytest.raises(HTTPException) as exc_info:
await submit_hole_score(
match_id,
HoleScoreCreate(
team_side="a", match_participant_id=participant_id, hole_number=1,
gross_strokes=9, expected_version=99,
),
org_id,
user=user,
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail["code"] == "STALE_VERSION"
async def test_hole_result_succeeds_and_increments_version(pool):
org_id, user_id, match_id, _, _ = await _setup_match(scoring_mode="hole_result")
user = CurrentUser(user_id=user_id)
first = await submit_hole_result(
match_id, HoleResultCreate(hole_number=1, winning_side="a", expected_version=None), org_id, user=user
)
assert first.version == 1
assert first.winning_side == "a"
second = await submit_hole_result(
match_id, HoleResultCreate(hole_number=1, winning_side="b", expected_version=1), org_id, user=user
)
assert second.version == 2
assert second.winning_side == "b"
async def test_hole_result_stale_version_is_409(pool):
org_id, user_id, match_id, _, _ = await _setup_match(scoring_mode="hole_result")
user = CurrentUser(user_id=user_id)
await submit_hole_result(
match_id, HoleResultCreate(hole_number=1, winning_side="a", expected_version=None), org_id, user=user
)
with pytest.raises(HTTPException) as exc_info:
await submit_hole_result(
match_id, HoleResultCreate(hole_number=1, winning_side="b", expected_version=99), org_id, user=user
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail["code"] == "STALE_VERSION"