//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 setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) { t.Helper() if _, err := testDB.ExecContext(context.Background(), `UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil { t.Fatalf("set wallet: %v", err) } } 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) 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) 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) setWallet(t, primary, 2, false) setWallet(t, secondary, 5, true) 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") } acc, err := store.GetByID(ctx, primary) if err != nil { t.Fatalf("get primary: %v", err) } if acc.HintBalance != 7 { t.Errorf("hint balance = %d, want 7", acc.HintBalance) } if !acc.PaidAccount { t.Error("paid_account should be true (ORed from secondary)") } 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) } } // 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") } } // 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 merges a guest initiator into the durable account // that owns the email: the durable account wins and a fresh session is minted. 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) if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil { t.Fatalf("confirm: %v", err) } merge, err := links.MergeEmail(ctx, guest, email, code) if err != nil { t.Fatalf("merge: %v", err) } if merge.PrimaryID != durable { t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable) } if 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) } }