//go:build integration package inttest import ( "context" "errors" "regexp" "testing" "github.com/google/uuid" "scrabble/backend/internal/account" ) // capturingMailer records the last message instead of sending it, so tests can // recover the confirm-code from the body. type capturingMailer struct{ lastBody string } func (m *capturingMailer) Send(_ context.Context, _, _, body string) error { m.lastBody = body return nil } var sixDigit = regexp.MustCompile(`\d{6}`) // TestEmailConfirmFlow covers the happy path: request a code, confirm it, and the // email becomes a confirmed identity of the account. func TestEmailConfirmFlow(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer) acc := provisionAccount(t) email := "user-" + uuid.NewString() + "@example.com" if err := svc.RequestCode(ctx, acc, email); err != nil { t.Fatalf("request code: %v", err) } code := sixDigit.FindString(mailer.lastBody) if code == "" { t.Fatalf("no code in mail body %q", mailer.lastBody) } // A wrong code is rejected without confirming. if _, err := svc.ConfirmCode(ctx, acc, email, "000000"); !errors.Is(err, account.ErrCodeMismatch) && !errors.Is(err, account.ErrTooManyAttempts) { t.Fatalf("wrong code = %v, want mismatch", err) } got, err := svc.ConfirmCode(ctx, acc, email, code) if err != nil { t.Fatalf("confirm code: %v", err) } if got.ID != acc { t.Errorf("confirmed account = %s, want %s", got.ID, acc) } if !identityConfirmed(t, account.KindEmail, email) { t.Error("email identity must be confirmed after a correct code") } } // TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a // different account (merge is a later stage). func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer) owner := provisionAccount(t) email := "taken-" + uuid.NewString() + "@example.com" if err := svc.RequestCode(ctx, owner, email); err != nil { t.Fatalf("owner request: %v", err) } if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil { t.Fatalf("owner confirm: %v", err) } other := provisionAccount(t) if err := svc.RequestCode(ctx, other, email); !errors.Is(err, account.ErrEmailTaken) { t.Fatalf("other request = %v, want ErrEmailTaken", err) } } // TestEmailCodeExpires rejects a code past its TTL (backdated directly). func TestEmailCodeExpires(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer) acc := provisionAccount(t) email := "expire-" + uuid.NewString() + "@example.com" if err := svc.RequestCode(ctx, acc, email); err != nil { t.Fatalf("request: %v", err) } code := sixDigit.FindString(mailer.lastBody) if _, err := testDB.ExecContext(ctx, `UPDATE backend.email_confirmations SET expires_at = now() - interval '1 minute' WHERE account_id = $1`, acc); err != nil { t.Fatalf("backdate expiry: %v", err) } if _, err := svc.ConfirmCode(ctx, acc, email, code); !errors.Is(err, account.ErrCodeExpired) { t.Fatalf("confirm expired = %v, want ErrCodeExpired", err) } } // TestEmailTooManyAttempts locks a code after the attempt cap. func TestEmailTooManyAttempts(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer) acc := provisionAccount(t) email := "lock-" + uuid.NewString() + "@example.com" if err := svc.RequestCode(ctx, acc, email); err != nil { t.Fatalf("request: %v", err) } // Five wrong tries are mismatches; the sixth is locked out. for i := 0; i < 5; i++ { if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrCodeMismatch) { t.Fatalf("attempt %d = %v, want ErrCodeMismatch", i+1, err) } } if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrTooManyAttempts) { t.Fatalf("after cap = %v, want ErrTooManyAttempts", err) } } // TestUpdateProfilePersists writes a full profile and reads it back. func TestUpdateProfilePersists(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) acc := provisionAccount(t) updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{ DisplayName: "Kaya", PreferredLanguage: "ru", TimeZone: "Europe/Moscow", BlockChat: true, BlockFriendRequests: true, }) if err != nil { t.Fatalf("update profile: %v", err) } if updated.DisplayName != "Kaya" || updated.PreferredLanguage != "ru" || updated.TimeZone != "Europe/Moscow" { t.Errorf("profile not applied: %+v", updated) } if !updated.BlockChat || !updated.BlockFriendRequests { t.Errorf("block toggles not applied: %+v", updated) } reloaded, err := store.GetByID(ctx, acc) if err != nil { t.Fatalf("reload: %v", err) } if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat { t.Errorf("profile did not persist: %+v", reloaded) } }