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).
This commit is contained in:
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
|
||||
// that owns the email: the durable account wins and a fresh session is minted.
|
||||
// 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)
|
||||
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
|
||||
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
merge, err := links.MergeEmail(ctx, guest, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %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 merge.PrimaryID != durable {
|
||||
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
|
||||
if confirm.Merge.PrimaryID != durable {
|
||||
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
|
||||
}
|
||||
if merge.SwitchedToken == "" {
|
||||
if confirm.Merge.SwitchedToken == "" {
|
||||
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
|
||||
}
|
||||
if mergedInto(t, guest) != durable {
|
||||
@@ -385,6 +388,33 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user