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
|
sto "Waiting" rett før runneren startet). `teecup_db`s rolleoppsett og
|
||||||
fravær av gjenglemte scratch-databaser bekreftet uendret etter kjøring.
|
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:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
||||||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
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
|
Forgejo sitt UI (grønn hake, samme kø-oppføring som nettopp sto
|
||||||
"Waiting"). `teecup_db`s rolleoppsett og fravær av gjenglemte
|
"Waiting"). `teecup_db`s rolleoppsett og fravær av gjenglemte
|
||||||
scratch-databaser bekreftet uendret etter kjøring.
|
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
|
# funksjon som frittstående runder fikk 2026-07-25). KUN meningsfullt for
|
||||||
# delt-ball-hull (match_participant_id er None) -- helt valgfritt.
|
# delt-ball-hull (match_participant_id er None) -- helt valgfritt.
|
||||||
selected_participant_id: str | None = None
|
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):
|
class HoleScoreOut(BaseModel):
|
||||||
|
|
@ -479,6 +485,7 @@ class HoleScoreOut(BaseModel):
|
||||||
match_participant_id: str | None
|
match_participant_id: str | None
|
||||||
gross_strokes: int
|
gross_strokes: int
|
||||||
selected_participant_id: str | None = None
|
selected_participant_id: str | None = None
|
||||||
|
version: int
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -579,6 +586,14 @@ async def submit_hole_score(
|
||||||
"Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
|
"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:
|
if body.match_participant_id is not None:
|
||||||
row = await conn.fetchrow(
|
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)
|
(organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
|
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,
|
RETURNING hole_number, team_side::text AS team_side,
|
||||||
match_participant_id::text AS match_participant_id, gross_strokes,
|
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,
|
organization_id,
|
||||||
match_id,
|
match_id,
|
||||||
|
|
@ -597,6 +614,7 @@ async def submit_hole_score(
|
||||||
body.match_participant_id,
|
body.match_participant_id,
|
||||||
body.hole_number,
|
body.hole_number,
|
||||||
body.gross_strokes,
|
body.gross_strokes,
|
||||||
|
body.expected_version,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
row = await conn.fetchrow(
|
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
|
ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
|
||||||
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
|
DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
|
||||||
selected_participant_id = EXCLUDED.selected_participant_id,
|
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,
|
RETURNING hole_number, team_side::text AS team_side,
|
||||||
match_participant_id::text AS match_participant_id, gross_strokes,
|
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,
|
organization_id,
|
||||||
match_id,
|
match_id,
|
||||||
|
|
@ -619,6 +638,12 @@ async def submit_hole_score(
|
||||||
body.hole_number,
|
body.hole_number,
|
||||||
body.gross_strokes,
|
body.gross_strokes,
|
||||||
body.selected_participant_id,
|
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)
|
await recompute_and_cache_match_state(conn, match_id)
|
||||||
|
|
@ -628,11 +653,14 @@ async def submit_hole_score(
|
||||||
class HoleResultCreate(BaseModel):
|
class HoleResultCreate(BaseModel):
|
||||||
hole_number: int = Field(ge=1, le=18)
|
hole_number: int = Field(ge=1, le=18)
|
||||||
winning_side: str | None = Field(default=None, pattern="^[ab]$")
|
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):
|
class HoleResultOut(BaseModel):
|
||||||
hole_number: int
|
hole_number: int
|
||||||
winning_side: str | None
|
winning_side: str | None
|
||||||
|
version: int
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -686,13 +714,21 @@ async def submit_hole_result(
|
||||||
INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
|
INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (match_id, hole_number)
|
ON CONFLICT (match_id, hole_number)
|
||||||
DO UPDATE SET winning_side = EXCLUDED.winning_side
|
DO UPDATE SET winning_side = EXCLUDED.winning_side,
|
||||||
RETURNING hole_number, winning_side::text AS 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,
|
organization_id,
|
||||||
match_id,
|
match_id,
|
||||||
body.hole_number,
|
body.hole_number,
|
||||||
body.winning_side,
|
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)
|
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,
|
SELECT hole_number, team_side::text AS team_side,
|
||||||
match_participant_id::text AS match_participant_id, gross_strokes,
|
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
|
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
|
||||||
""",
|
""",
|
||||||
match_id,
|
match_id,
|
||||||
|
|
@ -759,7 +795,7 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
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
|
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
|
||||||
""",
|
""",
|
||||||
match_id,
|
match_id,
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,11 @@ type ApiHoleScore = {
|
||||||
// funksjon som frittstående runder fikk 2026-07-25). Kun satt for
|
// funksjon som frittstående runder fikk 2026-07-25). Kun satt for
|
||||||
// delt-ball-rader (match_participant_id null).
|
// delt-ball-rader (match_participant_id null).
|
||||||
selected_participant_id: string | 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 = {
|
type ApiScorecard = {
|
||||||
match_id: string
|
match_id: string
|
||||||
|
|
@ -123,11 +125,20 @@ type Unit = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per
|
// 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 {
|
function strokeKey(holeNumber: number, unit: Unit): string {
|
||||||
return `${holeNumber}:${unit.side}:${unit.matchParticipantId ?? ""}`
|
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[] {
|
function playedHoleNumbers(holeConfig: HoleConfig): number[] {
|
||||||
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
||||||
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
||||||
|
|
@ -245,16 +256,21 @@ export function SessionScorecard({
|
||||||
hole_number: holeNumber,
|
hole_number: holeNumber,
|
||||||
gross_strokes: grossStrokes,
|
gross_strokes: grossStrokes,
|
||||||
selected_participant_id: selectedParticipantId,
|
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))
|
setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes))
|
||||||
setPendingCount((c) => c + 1)
|
setPendingCount((c) => c + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
async function queueHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
||||||
const url = `/orgs/${organizationId}/matches/${matchId}/hole-results`
|
const url = `/orgs/${organizationId}/matches/${matchId}/hole-results`
|
||||||
const body = { hole_number: holeNumber, winning_side: winningSide }
|
const body = {
|
||||||
await enqueueWrite({ url, method: "POST", body, matchId })
|
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))
|
setPendingResults((prev) => new Map(prev).set(holeNumber, winningSide))
|
||||||
setPendingCount((c) => c + 1)
|
setPendingCount((c) => c + 1)
|
||||||
}
|
}
|
||||||
|
|
@ -363,6 +379,21 @@ export function SessionScorecard({
|
||||||
return entry?.selected_participant_id ?? null
|
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
|
// `selectedParticipantId` utelatt = behold gjeldende valg uendret (brukt
|
||||||
// av selve slagregistreringen); eksplisitt sendt (inkl. `null`) = et nytt
|
// av selve slagregistreringen); eksplisitt sendt (inkl. `null`) = et nytt
|
||||||
// utslags-valg (brukt av "hvem sitt utslag ble brukt"-knappene under).
|
// utslags-valg (brukt av "hvem sitt utslag ble brukt"-knappene under).
|
||||||
|
|
@ -391,6 +422,7 @@ export function SessionScorecard({
|
||||||
hole_number: holeNumber,
|
hole_number: holeNumber,
|
||||||
gross_strokes: grossStrokes,
|
gross_strokes: grossStrokes,
|
||||||
selected_participant_id: resolvedSelection,
|
selected_participant_id: resolvedSelection,
|
||||||
|
expected_version: strokeVersionFor(holeNumber, unit),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -400,6 +432,14 @@ export function SessionScorecard({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
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.")
|
setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -443,13 +483,25 @@ export function SessionScorecard({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
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 {
|
} catch {
|
||||||
await queueHoleResult(holeNumber, winningSide)
|
await queueHoleResult(holeNumber, winningSide)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
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.")
|
setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ export type QueueEntry = {
|
||||||
body: unknown
|
body: unknown
|
||||||
matchId: string
|
matchId: string
|
||||||
createdAt: number
|
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> {
|
function openDb(): Promise<IDBDatabase> {
|
||||||
|
|
@ -84,23 +92,32 @@ export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
|
||||||
const entries = await listQueue(matchId)
|
const entries = await listQueue(matchId)
|
||||||
const outcomes: FlushOutcome[] = []
|
const outcomes: FlushOutcome[] = []
|
||||||
// Kjeder expected_version fremover for flere køede skrivinger til SAMME
|
// Kjeder expected_version fremover for flere køede skrivinger til SAMME
|
||||||
// URL i én flush-runde (2026-08-10, ADR-057 versjonssjekk). Uten dette
|
// RESSURS i én flush-runde (2026-08-10, ADR-057 versjonssjekk; nøkkel
|
||||||
// ville en spillers EGNE påfølgende offline-redigeringer av samme hull
|
// generalisert til resourceKey 2026-08-10, ADR-060). Uten dette ville en
|
||||||
// avvist hverandre som falske konflikter: kun den FØRSTE køede
|
// spillers EGNE påfølgende offline-redigeringer av samme rad avvist
|
||||||
// oppføringen har en expected_version som fortsatt stemmer med serveren
|
// hverandre som falske konflikter: kun den FØRSTE køede oppføringen har
|
||||||
// -- lokal state (og dermed enqueue-tidspunktets versjon) oppdateres
|
// en expected_version som fortsatt stemmer med serveren -- lokal state
|
||||||
// aldri mellom to sekvensielle avspillinger i samme batch, så oppføring
|
// (og dermed enqueue-tidspunktets versjon) oppdateres aldri mellom to
|
||||||
// 2 ville ellers sendt den samme (nå utdaterte) versjonen som 1.
|
// sekvensielle avspillinger i samme batch, så oppføring 2 ville ellers
|
||||||
const latestVersionByUrl = new Map<string, number>()
|
// 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) {
|
for (const entry of entries) {
|
||||||
|
const key = entry.resourceKey ?? entry.url
|
||||||
let body: unknown = entry.body
|
let body: unknown = entry.body
|
||||||
if (
|
if (
|
||||||
body !== null &&
|
body !== null &&
|
||||||
typeof body === "object" &&
|
typeof body === "object" &&
|
||||||
"expected_version" in body &&
|
"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
|
let res: Response
|
||||||
try {
|
try {
|
||||||
|
|
@ -116,7 +133,7 @@ export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated: { version?: number } | null = await res.json().catch(() => null)
|
const updated: { version?: number } | null = await res.json().catch(() => null)
|
||||||
if (updated && typeof updated.version === "number") {
|
if (updated && typeof updated.version === "number") {
|
||||||
latestVersionByUrl.set(entry.url, updated.version)
|
latestVersionByKey.set(key, updated.version)
|
||||||
}
|
}
|
||||||
await removeFromQueue(entry.id)
|
await removeFromQueue(entry.id)
|
||||||
outcomes.push({ entry, ok: true })
|
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,
|
hole_id, participant_id, hole_number, par, stroke_index, version,
|
||||||
)
|
)
|
||||||
return hole_id
|
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