fix(merge): carry moves/hints and per-variant best moves on account merge
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s

The account merge predated the recent statistics additions and silently dropped
them:
- account_stats.moves and hints_used were not summed into the primary (the
  secondary's row is read, then deleted), so the secondary's lifetime moves and
  hints were lost.
- account_best_move was not handled at all; the secondary is only tombstoned
  (not deleted), so its per-variant best moves lingered on the dead account and
  never reached the merged statistics screen.

mergeStats now also sums moves + hints_used; a new mergeBestMoves folds the
secondary's best moves into the primary keeping the higher-scoring play per
variant (the rule the per-game upsert uses), then deletes the secondary's rows.
Profile fields were already correct (hint wallet summed, paid_account ORed; no
new accounts columns since).

Tests: TestAccountMergeCore extended — moves/hints summed, best moves merged
(higher score per variant kept), secondary rows cleaned up. Docs: ARCHITECTURE §4.
This commit is contained in:
Ilia Denisov
2026-06-17 23:56:04 +02:00
parent 6d1d8030e3
commit ed7c6ac56d
3 changed files with 103 additions and 5 deletions
+55
View File
@@ -30,6 +30,34 @@ func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
}
}
func setStatsCounts(t *testing.T, id uuid.UUID, moves, hints int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.account_stats SET moves=$2, hints_used=$3 WHERE account_id=$1`, id, moves, hints); err != nil {
t.Fatalf("set stats counts: %v", err)
}
}
func setBestMove(t *testing.T, id uuid.UUID, variant string, score int) {
t.Helper()
tiles := `[{"letter":"c","value":3,"blank":false}]`
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.account_best_move (account_id, variant, score, tiles) VALUES ($1,$2,$3,$4)
ON CONFLICT (account_id, variant) DO UPDATE SET score=$3, tiles=$4`, id, variant, score, tiles); err != nil {
t.Fatalf("set best move: %v", err)
}
}
func bestMoveCount(t *testing.T, id uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.account_best_move WHERE account_id=$1`, id).Scan(&n); err != nil {
t.Fatalf("best move count: %v", err)
}
return n
}
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
@@ -110,8 +138,15 @@ func TestAccountMergeCore(t *testing.T) {
setStats(t, primary, 1, 0, 0, 100, 90)
setStats(t, secondary, 3, 1, 2, 400, 80)
setStatsCounts(t, primary, 100, 5)
setStatsCounts(t, secondary, 50, 8)
setWallet(t, primary, 2, false)
setWallet(t, secondary, 5, true)
// Best moves: secondary's scrabble_en (80) beats primary's (50) and is kept; secondary's
// scrabble_ru (30) is new to primary and carried over.
setBestMove(t, primary, "scrabble_en", 50)
setBestMove(t, secondary, "scrabble_en", 80)
setBestMove(t, secondary, "scrabble_ru", 30)
email := "merge-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, secondary, email)
@@ -153,6 +188,26 @@ func TestAccountMergeCore(t *testing.T) {
if mergedInto(t, secondary) != primary {
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
}
// The moves/hints counters sum, and the per-variant best moves merge (the higher score
// per variant kept), with the secondary's best-move rows cleaned up.
st, err := store.GetStats(ctx, primary)
if err != nil {
t.Fatalf("get merged stats: %v", err)
}
if st.Moves != 150 || st.HintsUsed != 13 {
t.Errorf("primary moves/hints = %d/%d, want 150/13", st.Moves, st.HintsUsed)
}
best := map[string]int{}
for _, b := range st.BestMoves {
best[b.Variant] = b.Score
}
if len(st.BestMoves) != 2 || best["scrabble_en"] != 80 || best["scrabble_ru"] != 30 {
t.Errorf("primary best moves = %+v, want scrabble_en:80 + scrabble_ru:30", st.BestMoves)
}
if bestMoveCount(t, secondary) != 0 {
t.Error("secondary best-move rows should be deleted after merge")
}
}
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active