Files
scrabble-game/backend/internal/inttest/merge_test.go
T
Ilia Denisov da25eac070 feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)
When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
2026-07-15 00:34:58 +02:00

478 lines
18 KiB
Go

//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/session"
)
// --- merge test helpers ---
func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`,
id, w, l, d, mg, mw); err != nil {
t.Fatalf("set stats: %v", err)
}
}
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 bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
uuid.New(), acc, email); err != nil {
t.Fatalf("bind email identity: %v", err)
}
}
func insertFriendship(t *testing.T, a, b uuid.UUID, status string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil {
t.Fatalf("insert friendship: %v", err)
}
}
func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID {
t.Helper()
var into *uuid.UUID
if err := testDB.QueryRowContext(context.Background(),
`SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil {
t.Fatalf("read merged_into: %v", err)
}
if into == nil {
return uuid.Nil
}
return *into
}
func seatCount(t *testing.T, gameID, accountID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil {
t.Fatalf("seat count: %v", err)
}
return n
}
func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID {
t.Helper()
g, err := newGameService().Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("seat game: %v", err)
}
return g.ID
}
func newLinkService(mailer account.Mailer) *link.Service {
store := account.NewStore(testDB)
emails := account.NewEmailService(store, mailer, "https://erudit-game.ru")
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
}
// TestAccountMergeCore folds every account-scoped artifact of a secondary into a
// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a
// non-shared game transferred, a friend carried over, and the secondary tombstoned.
func TestAccountMergeCore(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
merger.SetPayments(newPaymentsService())
primary := provisionAccount(t)
secondary := provisionAccount(t)
friend := provisionAccount(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)
seedBenefit(t, primary, "telegram", 2, false)
seedBenefit(t, secondary, "telegram", 5, true) // hints sum, forever OR-s on merge
// 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)
insertFriendship(t, secondary, friend, "accepted")
gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
w, l, d, mg, mw, found := readStats(t, primary)
if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 {
t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found)
}
if _, _, _, _, _, found := readStats(t, secondary); found {
t.Error("secondary stats row should be deleted after merge")
}
// The payments wallet merged by origin: hints sum (2+5) and the forever flag OR-s; the
// deprecated accounts.hint_balance / paid_account columns are no longer touched by a merge.
if hints, forever := readBenefit(t, primary, "telegram"); hints != 7 || !forever {
t.Errorf("merged telegram benefit = hints %d forever %v, want 7/true", hints, forever)
}
if hints, _ := readBenefit(t, secondary, "telegram"); hints != 0 {
t.Errorf("secondary benefit should be cleared after merge, got hints %d", hints)
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 {
t.Error("non-shared game seat should transfer to primary")
}
if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend {
t.Errorf("primary friends = %v, want [%s]", friends, friend)
}
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
// game (one player cannot be merged against themselves).
func TestAccountMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("merge = %v, want ErrActiveGameConflict", err)
}
if mergedInto(t, secondary) != uuid.Nil {
t.Error("a refused merge must not tombstone the secondary")
}
}
// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is
// finished and leaves the secondary's seat in place (the tombstone keeps the
// no-cascade foreign key valid).
func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
ctx := context.Background()
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil {
t.Fatalf("finish game: %v", err)
}
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 {
t.Error("a shared finished game must keep both seats (secondary as tombstone)")
}
if mergedInto(t, secondary) != primary {
t.Error("secondary should be tombstoned")
}
}
// TestAccountMergeDedupesEmail keeps the primary's email when both accounts have one and
// journals the secondary's to the dossier (reason=merge), so the survivor never ends up
// with two email identities.
func TestAccountMergeDedupesEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
primaryEmail := "keep-" + uuid.NewString() + "@example.com"
secondaryEmail := "absorb-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, primary, primaryEmail)
bindEmailIdentity(t, secondary, secondaryEmail)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
// The primary keeps its own email; the secondary's is gone from the live identities.
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, primaryEmail); !ok || owner != primary {
t.Errorf("primary email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if _, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, secondaryEmail); ok {
t.Error("the secondary's email must be removed (no duplicate email on the survivor)")
}
// The absorbed email stays in the legal dossier, tagged reason=merge.
var reason string
if err := testDB.QueryRowContext(ctx,
`SELECT reason FROM backend.retained_identities WHERE account_id=$1 AND kind='email' AND external_id=$2`,
secondary, secondaryEmail).Scan(&reason); err != nil {
t.Fatalf("retained email row: %v", err)
}
if reason != "merge" {
t.Errorf("retained reason = %q, want merge", reason)
}
}
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
guest := provisionGuest(t)
email := "fresh-" + uuid.NewString() + "@example.com"
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody))
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
acc, _ := store.GetByID(ctx, guest)
if acc.IsGuest {
t.Error("guest flag should clear once an identity is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest {
t.Errorf("email owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current
// (durable) account: the caller stays primary and keeps its session.
func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
caller := provisionAccount(t)
other := provisionAccount(t)
email := "owned-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, other, email)
if err := links.RequestEmail(ctx, caller, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, caller, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeEmail(ctx, caller, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller {
t.Errorf("email owner = %s, want caller", owner)
}
}
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "durable-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
if !confirm.Merged || confirm.MergeRequired {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
}
if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
}
if confirm.Merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
t.Error("the guest should be tombstoned into the durable account")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable {
t.Errorf("email owner = %s, want durable", owner)
}
}
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
guest := provisionGuest(t)
vkID := "vk-" + uuid.NewString()
res, err := links.ConfirmVK(ctx, guest, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
if acc, _ := store.GetByID(ctx, guest); acc.IsGuest {
t.Error("guest flag should clear once VK is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); !ok || owner != guest {
t.Errorf("vk owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkVKMergeIntoCaller merges the account owning a VK identity into the
// current durable account: ConfirmVK previews the merge, MergeVK folds it, the caller
// stays primary and keeps its session, and the VK identity repoints to the caller.
func TestAccountLinkVKMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
links := newLinkService(&capturingMailer{})
caller := provisionAccount(t)
other := provisionAccount(t)
vkID := "vk-" + uuid.NewString()
if err := store.AttachIdentity(ctx, other, account.KindVK, vkID, true); err != nil {
t.Fatalf("seed vk on other: %v", err)
}
confirm, err := links.ConfirmVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("confirm vk: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeVK(ctx, caller, vkID)
if err != nil {
t.Fatalf("merge vk: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindVK, vkID); owner != caller {
t.Errorf("vk owner = %s, want caller after merge", owner)
}
}