From 5b80c9733753f91d64c46f547ed39f1920b856ce Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:06:10 +0100 Subject: [PATCH] test: cover PostgreSQL season rollover --- multiplayer-todo.md | 2 +- server/store/postgres_integration_test.go | 42 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index a794aa2f..0f1b0dbf 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1201,7 +1201,7 @@ the local/CI/community transport, not a silent production fallback. | 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas; proposal preparation requires matching metadata for every formed player | `server/domain/ranked.go`, `formation.go` cover count, identity, party, bot/backfill, arena eligibility and formed-player metadata rejection; `ArenaRegistry` integration, allocation wiring and innocent-ticket restoration remain | | 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; persisted tier policy, client UI and reconnect transport remain | -| 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/season_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, row locking and conflict-safe rollover markers; live PostgreSQL execution and maintenance scheduler remain | +| 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/season_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, row locking and conflict-safe rollover markers; opt-in PostgreSQL execution now covers the durable rating update, marker creation and duplicate replay without a second compression; maintenance scheduler remains | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder; canonical signed authorisation issuance/verification now gates admission | `server/domain/reconnect.go`, `join_auth.go` and adversarial fixtures cover repeated backend-independent reclaim, all signed claim binding, tampering, failed verification, old-generation fencing, grace boundary and deterministic cooldown audit ordering; persistent lease fencing, join transport and full match/result integration remain | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL now executes the receipt → match lock → completion → receipt acknowledgment → outbox boundary atomically, and exposes bounded ordered outbox reads plus publish acknowledgements for replayable fan-out; `OutboxDispatcher` now delivers in order and acknowledges only after successful fan-out | `server/domain/result.go`, `workload.go`, `server/store/result_sql.go` and `outbox.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit ordering, idempotent SQL reconciliation, unpublished-event replay/ack boundaries and delivery-before-ack failure ordering; opt-in PostgreSQL execution now covers result-pending completion, durable receipt/outbox publication, ack removal, identical replay and conflicting replay rejection; production credential verification, Agones annotation persistence/reconciliation, rating-lock integration and integrity evidence adapters remain | diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 391b2a65..e13b1bc4 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -285,6 +285,48 @@ func TestPostgreSQLResultCompletionAndOutboxAreAtomicAndReplayable(t *testing.T) } } +func TestPostgreSQLRankedSeasonRolloverIsExactlyOnce(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + now := time.Now().UTC().Truncate(time.Microsecond) + ctx := context.Background() + if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('season-player', 'season-steam')`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO ratings (player_id, rating, deviation, volatility, ranked_games) VALUES ('season-player', 1900, 100, 0.12, 25)`); err != nil { + t.Fatal(err) + } + profile := domain.RankedProfile{Rating: domain.Rating{Value: 1900, RD: 100, Volatility: 0.12}, RankedGames: 25} + updated, applied, err := ApplyRankedSeasonRollover(ctx, db, "season-player", "season-1", profile, now) + if err != nil || !applied { + t.Fatalf("first season rollover = %+v applied=%v err=%v", updated, applied, err) + } + if updated.Value != 1800 || updated.RD != 200 { + t.Fatalf("unexpected rolled rating: %+v", updated) + } + var rating float64 + var markers int + if err := db.QueryRow(`SELECT rating FROM ratings WHERE player_id = 'season-player'`).Scan(&rating); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`SELECT count(*) FROM ranked_season_rollovers WHERE player_id = 'season-player' AND season_id = 'season-1'`).Scan(&markers); err != nil { + t.Fatal(err) + } + if rating != 1800 || markers != 1 { + t.Fatalf("durable rollover state rating=%v markers=%d", rating, markers) + } + _, applied, err = ApplyRankedSeasonRollover(ctx, db, "season-player", "season-1", profile, now.Add(time.Second)) + if err != nil || applied { + t.Fatalf("duplicate season rollover applied=%v err=%v", applied, err) + } + if err := db.QueryRow(`SELECT rating FROM ratings WHERE player_id = 'season-player'`).Scan(&rating); err != nil { + t.Fatal(err) + } + if rating != 1800 { + t.Fatalf("duplicate rollover changed rating to %v", rating) + } +} + func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db)