test: email_verified_at writers, non-writers and the migration
- TestEmailVerifiedAt: register (auto, admin), activate, activate-by-code, reset-password, change-password, update - TestAdminEmailVerifiedAt: admin create, activate action, invitation link, email change, JSON - TestEmailVerifiedAtMigration: column shape, no backfill, existing column kept, rollback keeps column
This commit is contained in:
377
email_verified_test.go
Normal file
377
email_verified_test.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// proofOf reads users.email_verified_at of the user id.
|
||||
func proofOf(t *testing.T, gdb *gorm.DB, id uint) *time.Time {
|
||||
t.Helper()
|
||||
var got *time.Time
|
||||
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE id = ?`, id).Row().Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// userIDByEmail returns the id of the user with email.
|
||||
func userIDByEmail(t *testing.T, gdb *gorm.DB, email string) uint {
|
||||
t.Helper()
|
||||
var u models.User
|
||||
if err := gdb.Unscoped().Where("lower(email) = lower(?)", email).Take(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// setProof stores a proof time on the user id and returns it.
|
||||
func setProof(t *testing.T, gdb *gorm.DB, id uint) time.Time {
|
||||
t.Helper()
|
||||
at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||
if err := gdb.Exec(`UPDATE users SET email_verified_at = ? WHERE id = ?`, at, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// assertStamped requires got to be set between before and after (with a
|
||||
// millisecond of slack for the database's microsecond precision).
|
||||
func assertStamped(t *testing.T, got *time.Time, before, after time.Time) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatal("email_verified_at is NULL, want the time of the proof")
|
||||
}
|
||||
if got.Before(before.Add(-time.Millisecond)) || got.After(after.Add(time.Millisecond)) {
|
||||
t.Fatalf("email_verified_at = %v, want between %v and %v", got, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoProofKey requires a response body without the proof column.
|
||||
func assertNoProofKey(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
if strings.Contains(body, "email_verified_at") {
|
||||
t.Fatalf("the payload carries email_verified_at: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailVerifiedAt pins the user API side of D-26 and D-27: only the
|
||||
// mailed activation code and a completed reset-code password reset record
|
||||
// the proof of the address, an email change clears it, and the column is
|
||||
// never mass-assigned or part of a payload.
|
||||
func TestEmailVerifiedAt(t *testing.T) {
|
||||
app, gdb := sessionApp(t, "0s")
|
||||
disableRegisterThrottle(t, app)
|
||||
if err := app.Publish[postcard.Mailer](&captureMail{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamp := adminStamp()
|
||||
email := func(label string) string { return "proof-" + label + "-" + stamp + "@example.test" }
|
||||
mode := func(t *testing.T, m string) {
|
||||
t.Helper()
|
||||
if err := app.Config.Set("golem15.user.activation.activate_mode", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
register := func(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoProofKey(t, rec.Body.String())
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
t.Run("register_auto_no_proof", func(t *testing.T) {
|
||||
mode(t, "auto")
|
||||
addr := email("register-auto")
|
||||
body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`)
|
||||
if !strings.Contains(body, `"token":`) {
|
||||
t.Fatalf("auto-mode register answered %s", body)
|
||||
}
|
||||
id := userIDByEmail(t, gdb, addr)
|
||||
var activated bool
|
||||
if err := gdb.Raw(`SELECT is_activated FROM users WHERE id = ?`, id).Row().Scan(&activated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !activated || proofOf(t, gdb, id) != nil {
|
||||
t.Fatalf("auto-mode account: activated=%v proof=%v", activated, proofOf(t, gdb, id))
|
||||
}
|
||||
})
|
||||
t.Run("register_admin_mode_no_proof", func(t *testing.T) {
|
||||
mode(t, "admin")
|
||||
t.Cleanup(func() { mode(t, "auto") })
|
||||
addr := email("register-admin")
|
||||
if body := register(t, `{"email":"`+addr+`","password":"correct-horse"}`); body != `{}` {
|
||||
t.Fatalf("admin-mode register answered %s", body)
|
||||
}
|
||||
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||
t.Fatalf("admin-mode account has proof %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("register_ignores_submitted_proof", func(t *testing.T) {
|
||||
mode(t, "auto")
|
||||
addr := email("register-submitted")
|
||||
register(t, `{"email":"`+addr+`","password":"correct-horse","email_verified_at":"2020-01-01T00:00:00Z"}`)
|
||||
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||
t.Fatalf("a submitted email_verified_at was stored: %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("activate_by_code_stamps", func(t *testing.T) {
|
||||
user := insertUser(t, gdb, email("activate-by-code"), "correct-horse")
|
||||
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := time.Now()
|
||||
rec := postJSON(t, controllers.ActivateByCode(app), "/_user/api/v1/activate-by-code",
|
||||
`{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`"}`)
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoProofKey(t, rec.Body.String())
|
||||
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
|
||||
})
|
||||
t.Run("activate_stamps", func(t *testing.T) {
|
||||
addr := email("activate")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
|
||||
code, err := classes.IssueActivationCode(t.Context(), gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := time.Now()
|
||||
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"`+code+`"}`)
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":true`) {
|
||||
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoProofKey(t, rec.Body.String())
|
||||
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
|
||||
})
|
||||
t.Run("activate_wrong_code_no_proof", func(t *testing.T) {
|
||||
addr := email("activate-wrong")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
user := &models.User{ID: userIDByEmail(t, gdb, addr)}
|
||||
if _, err := classes.IssueActivationCode(t.Context(), gdb, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := bearerJSON(t, controllers.Activate(app), "/_user/api/v1/activate", token, `{"code":"wrong"}`)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"is_activated":false`) {
|
||||
t.Fatalf("activate %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoProofKey(t, rec.Body.String())
|
||||
if got := proofOf(t, gdb, user.ID); got != nil {
|
||||
t.Fatalf("a wrong activation code recorded proof %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("reset_password_stamps", func(t *testing.T) {
|
||||
user := insertUser(t, gdb, email("reset"), "correct-horse")
|
||||
code, err := classes.IssueResetCode(t.Context(), gdb, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := time.Now()
|
||||
rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password",
|
||||
`{"code":"`+strconv.FormatUint(uint64(user.ID), 10)+"!"+code+`","password":"new-password","password_confirmation":"new-password"}`)
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Password has been reset"}` {
|
||||
t.Fatalf("reset %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertStamped(t, proofOf(t, gdb, user.ID), before, after)
|
||||
})
|
||||
t.Run("reset_wrong_code_no_proof", func(t *testing.T) {
|
||||
user := insertUser(t, gdb, email("reset-wrong"), "correct-horse")
|
||||
if _, err := classes.IssueResetCode(t.Context(), gdb, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, code := range []string{strconv.FormatUint(uint64(user.ID), 10) + "!nope", "not-a-code"} {
|
||||
rec := postJSON(t, controllers.ResetPassword(app), "/_user/api/v1/reset-password",
|
||||
`{"code":"`+code+`","password":"new-password","password_confirmation":"new-password"}`)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("reset with %q: %d %s", code, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := proofOf(t, gdb, user.ID); got != nil {
|
||||
t.Fatalf("a refused reset recorded proof %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("change_password_no_proof", func(t *testing.T) {
|
||||
addr := email("change-password")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
// change-password replaces a self-set password only.
|
||||
if err := gdb.Exec(`UPDATE users SET has_self_set_password = TRUE WHERE id = ?`, userIDByEmail(t, gdb, addr)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token,
|
||||
`{"current_password":"correct-horse","password":"brand-new-password","password_confirmation":"brand-new-password"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("change-password %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoProofKey(t, rec.Body.String())
|
||||
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||
t.Fatalf("change-password recorded proof %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
update := func(t *testing.T, token, body string) string {
|
||||
t.Helper()
|
||||
rec := bearerJSON(t, controllers.Update(app), "/_user/api/v1/update", token, body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoProofKey(t, rec.Body.String())
|
||||
return rec.Body.String()
|
||||
}
|
||||
t.Run("update_email_change_clears", func(t *testing.T) {
|
||||
addr := email("update-change")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
id := userIDByEmail(t, gdb, addr)
|
||||
setProof(t, gdb, id)
|
||||
moved := email("update-changed-to")
|
||||
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+moved+`"}`)
|
||||
if got := proofOf(t, gdb, id); got != nil {
|
||||
t.Fatalf("an email change kept the proof %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("update_same_email_keeps", func(t *testing.T) {
|
||||
addr := email("update-same")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
id := userIDByEmail(t, gdb, addr)
|
||||
want := setProof(t, gdb, id)
|
||||
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`"}`)
|
||||
if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) {
|
||||
t.Fatalf("the same email changed the proof to %v", got)
|
||||
}
|
||||
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+strings.ToUpper(addr)+`"}`)
|
||||
if got := proofOf(t, gdb, id); got == nil || !got.Equal(want) {
|
||||
t.Fatalf("a case-only change dropped the proof: %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("update_ignores_submitted_proof", func(t *testing.T) {
|
||||
addr := email("update-submitted")
|
||||
token := loginToken(t, app, gdb, addr)
|
||||
update(t, token, `{"name":"Ann","surname":"Owner","email":"`+addr+`","email_verified_at":"2020-01-01T00:00:00Z"}`)
|
||||
if got := proofOf(t, gdb, userIDByEmail(t, gdb, addr)); got != nil {
|
||||
t.Fatalf("a submitted email_verified_at was stored: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAdminEmailVerifiedAt pins the admin side of D-26 and D-27: an
|
||||
// administrator's create and Activate action record no proof, the
|
||||
// invitation link (the mailed activation code) does, an email change on the
|
||||
// form clears it, and the admin JSON never carries it.
|
||||
func TestAdminEmailVerifiedAt(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
stamp := adminStamp()
|
||||
const password = "proof-admin-password-1"
|
||||
create := func(t *testing.T, email string, extra map[string]any) uint {
|
||||
t.Helper()
|
||||
body := map[string]any{"name": "Proof", "email": email, "password": password, "password_confirmation": password}
|
||||
for k, v := range extra {
|
||||
body[k] = v
|
||||
}
|
||||
return createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, body))
|
||||
}
|
||||
proven := func(t *testing.T, label string) *models.User {
|
||||
t.Helper()
|
||||
at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||
return env.seedUser(t, label, func(u *models.User) { u.EmailVerifiedAt = &at })
|
||||
}
|
||||
|
||||
t.Run("admin_create_no_proof", func(t *testing.T) {
|
||||
id := create(t, "admin-proof-create-"+stamp+"@example.test", nil)
|
||||
if got := env.reload(t, id); got == nil || got.EmailVerifiedAt != nil {
|
||||
t.Fatalf("admin-created user = %+v", got)
|
||||
}
|
||||
})
|
||||
t.Run("admin_activate_action_no_proof", func(t *testing.T) {
|
||||
user := env.seedUser(t, "admin-proof-activate", func(u *models.User) { u.IsActivated = false })
|
||||
if rec := env.bulk(t, token, "activate", user); rec.Code != http.StatusOK {
|
||||
t.Fatalf("activate action %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got := env.reload(t, user.ID)
|
||||
if !got.IsActivated || got.EmailVerifiedAt != nil {
|
||||
t.Fatalf("after the activate action: activated=%v proof=%v", got.IsActivated, got.EmailVerifiedAt)
|
||||
}
|
||||
})
|
||||
t.Run("admin_invite_link_stamps", func(t *testing.T) {
|
||||
invited := "admin-proof-invite-" + stamp + "@example.test"
|
||||
id := create(t, invited, map[string]any{"send_invite": true})
|
||||
if got := env.reload(t, id); got.EmailVerifiedAt != nil {
|
||||
t.Fatalf("the invitation recorded proof before the link: %v", got.EmailVerifiedAt)
|
||||
}
|
||||
mails := env.mails(invited)
|
||||
if len(mails) != 1 {
|
||||
t.Fatalf("invitations sent = %d", len(mails))
|
||||
}
|
||||
start := strings.Index(mails[0].Text, "https://app.example.test/activate?code=")
|
||||
if start < 0 {
|
||||
t.Fatalf("no activation link in %q", mails[0].Text)
|
||||
}
|
||||
link := mails[0].Text[start:]
|
||||
link = link[:strings.IndexAny(link, ")\n ")]
|
||||
parsed, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := time.Now()
|
||||
rec := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": parsed.Query().Get("code")})
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("activate-by-code %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got := env.reload(t, id)
|
||||
if !got.IsActivated {
|
||||
t.Fatal("the invitation link did not activate")
|
||||
}
|
||||
assertStamped(t, got.EmailVerifiedAt, before, after)
|
||||
})
|
||||
t.Run("admin_email_change_clears", func(t *testing.T) {
|
||||
user := proven(t, "admin-proof-change")
|
||||
moved := "admin-proof-moved-" + stamp + "@example.test"
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Moved", "email": moved})
|
||||
got := env.reload(t, user.ID)
|
||||
if got.Email != moved || got.EmailVerifiedAt != nil {
|
||||
t.Fatalf("after the email change: email=%s proof=%v", got.Email, got.EmailVerifiedAt)
|
||||
}
|
||||
})
|
||||
t.Run("admin_save_keeps_proof", func(t *testing.T) {
|
||||
user := proven(t, "admin-proof-keep")
|
||||
want := *user.EmailVerifiedAt
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": user.Email})
|
||||
if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) {
|
||||
t.Fatalf("a save with the same email changed the proof to %v", got.EmailVerifiedAt)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "Renamed", "email": strings.ToUpper(user.Email)})
|
||||
if got := env.reload(t, user.ID); got.EmailVerifiedAt == nil || !got.EmailVerifiedAt.Equal(want) {
|
||||
t.Fatalf("a case-only change dropped the proof: %v", got.EmailVerifiedAt)
|
||||
}
|
||||
})
|
||||
t.Run("admin_json_hides_proof", func(t *testing.T) {
|
||||
user := proven(t, "admin-proof-json")
|
||||
show := env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil)
|
||||
assertNoProofKey(t, show.Body.String())
|
||||
form := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil)
|
||||
assertNoProofKey(t, form.Body.String())
|
||||
list := env.users(t, token, "search="+url.QueryEscape(user.Email))
|
||||
if !list.has(user.ID) {
|
||||
t.Fatal("the proven user is not in the list")
|
||||
}
|
||||
assertNoProofKey(t, list.Raw)
|
||||
})
|
||||
}
|
||||
143
updates/email_verified_at_test.go
Normal file
143
updates/email_verified_at_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const emailVerifiedAtID = "202610210001_add_users_email_verified_at"
|
||||
|
||||
// emailVerifiedDB opens a dedicated database and a migrator over every
|
||||
// registered migration.
|
||||
func emailVerifiedDB(t *testing.T, name string) (*gorm.DB, *gormigrate.Gormigrate) {
|
||||
t.Helper()
|
||||
gdb, err := lagoon.Use(t.Context(), dedicatedDB(t, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return gdb, gormigrate.New(gdb, &gormigrate.Options{
|
||||
TableName: "summer_migrations_golem15_user",
|
||||
UseTransaction: true,
|
||||
}, All())
|
||||
}
|
||||
|
||||
// TestEmailVerifiedAtMigration pins D-27: users.email_verified_at is a
|
||||
// nullable TIMESTAMPTZ added only when missing, the migration writes no
|
||||
// rows (no backfill, D-26), a column that already exists keeps its values
|
||||
// and the rollback leaves it in place.
|
||||
func TestEmailVerifiedAtMigration(t *testing.T) {
|
||||
t.Run("column_shape", func(t *testing.T) {
|
||||
gdb, m := emailVerifiedDB(t, "user_email_verified_shape")
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row struct {
|
||||
DataType string
|
||||
IsNullable string
|
||||
}
|
||||
if err := gdb.Raw(`SELECT data_type, is_nullable FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'email_verified_at'`).Scan(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.DataType != "timestamp with time zone" || row.IsNullable != "YES" {
|
||||
t.Fatalf("email_verified_at = %+v", row)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no_backfill", func(t *testing.T) {
|
||||
gdb, m := emailVerifiedDB(t, "user_email_verified_backfill")
|
||||
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gdb.Migrator().HasColumn("users", "email_verified_at") {
|
||||
t.Fatal("setup: the column exists before the migration")
|
||||
}
|
||||
for _, email := range []string{"legacy-a@example.test", "legacy-b@example.test"} {
|
||||
if err := gdb.Exec(`INSERT INTO users (email, password, is_activated, has_self_set_password, activated_at)
|
||||
VALUES (?, 'hash', TRUE, TRUE, NOW())`, email).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var total, proven int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*), COUNT(email_verified_at) FROM users`).Row().Scan(&total, &proven); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || proven != 0 {
|
||||
t.Fatalf("users %d, with proof %d: the migration must write no rows", total, proven)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing_column_kept", func(t *testing.T) {
|
||||
gdb, m := emailVerifiedDB(t, "user_email_verified_existing")
|
||||
if err := m.MigrateTo("202610160002_add_handle_to_users"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A Laravel-derived schema already carries the column and its values.
|
||||
if err := gdb.Exec(`ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ NULL`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if err := gdb.Exec(`INSERT INTO users (email, email_verified_at) VALUES ('imported@example.test', ?)`, want).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`INSERT INTO users (email) VALUES ('imported-unproven@example.test')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ran int64
|
||||
if err := gdb.Raw(`SELECT COUNT(*) FROM summer_migrations_golem15_user WHERE id = ?`, emailVerifiedAtID).Scan(&ran).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ran != 1 {
|
||||
t.Fatal("the migration did not run over the existing column")
|
||||
}
|
||||
var got *time.Time
|
||||
if err := gdb.Raw(`SELECT email_verified_at FROM users WHERE email = 'imported@example.test'`).Row().Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || !got.Equal(want) {
|
||||
t.Fatalf("existing value = %v, want %v", got, want)
|
||||
}
|
||||
var proven int64
|
||||
if err := gdb.Raw(`SELECT COUNT(email_verified_at) FROM users`).Row().Scan(&proven); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proven != 1 {
|
||||
t.Fatalf("rows with proof = %d, want only the imported one", proven)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rollback_keeps_column", func(t *testing.T) {
|
||||
gdb, m := emailVerifiedDB(t, "user_email_verified_rollback")
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackMigration(usersEmailVerifiedAtMigrations[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gdb.Migrator().HasColumn("users", "email_verified_at") {
|
||||
t.Fatal("the rollback dropped the shared users column")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("history_once", func(t *testing.T) {
|
||||
n := 0
|
||||
for _, mig := range All() {
|
||||
if mig.ID == emailVerifiedAtID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 || len(usersEmailVerifiedAtMigrations) != 1 || usersEmailVerifiedAtMigrations[0].ID != emailVerifiedAtID {
|
||||
t.Fatalf("%s registered %d times", emailVerifiedAtID, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user