Optimistisk versjonssjekk for org-turneringers match-scoring
All checks were successful
Backend-tester / test (push) Successful in 21s
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:
parent
b65491afc8
commit
5c2455c1fe
8 changed files with 559 additions and 25 deletions
4
063_hole_score_version.sql
Normal file
4
063_hole_score_version.sql
Normal 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;
|
||||
|
|
@ -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å
|
||||
|
|
|
|||
31
CHANGELOG.md
31
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.
|
||||
|
|
|
|||
|
|
@ -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,13 +714,21 @@ 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
203
tests/test_scoring_concurrency.py
Normal file
203
tests/test_scoring_concurrency.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue