diff --git a/063_hole_score_version.sql b/063_hole_score_version.sql new file mode 100644 index 0000000..ee851b9 --- /dev/null +++ b/063_hole_score_version.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 6879420..2fc4d1a 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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 ` 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å diff --git a/CHANGELOG.md b/CHANGELOG.md index 2868e11..266b94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/routers/scoring.py b/app/routers/scoring.py index cc4603b..da4bd3e 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -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, diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx index 6ff7d1b..5618669 100644 --- a/frontend/components/session-scorecard.tsx +++ b/frontend/components/session-scorecard.tsx @@ -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 } diff --git a/frontend/lib/offline-queue.ts b/frontend/lib/offline-queue.ts index ee03fb3..6d8d38c 100644 --- a/frontend/lib/offline-queue.ts +++ b/frontend/lib/offline-queue.ts @@ -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 { @@ -84,23 +92,32 @@ export async function flushQueue(matchId: string): Promise { 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() + // 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() 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 { 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 }) diff --git a/tests/conftest.py b/tests/conftest.py index 339f1ef..d19a6d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_scoring_concurrency.py b/tests/test_scoring_concurrency.py new file mode 100644 index 0000000..2714faf --- /dev/null +++ b/tests/test_scoring_concurrency.py @@ -0,0 +1,203 @@ +""" +ADR-060: samme optimistiske versjonssjekk som round_hole (ADR-057) fikk, +nå 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"