// Copyright 2023 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package db import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/tamakyi/TamaBox/internal/conf" ) // requireUserEqual 比较期望与实际的 User。 // bcrypt 哈希每次加盐不同,无法逐字节比较:先按明文校验密码,再把哈希字段对齐后比较其余字段。 // Create 会按 conf.Limits.ChatMaxMessages 显式写入该字段,故期望值同步取该配置默认。 func requireUserEqual(t *testing.T, want, got *User) { t.Helper() require.True(t, got.Authenticate(want.Password)) want.Password = got.Password want.ChatMaxMessages = conf.Limits.ChatMaxMessages want.HarassmentSetting = HarassmentSettingNone // Create 显式写入 none require.Equal(t, want, got) } func TestUsers(t *testing.T) { // 注意:本测试各子测试创建用户均使用固定邮箱/域名(i@github.red / e99), // 且每个子测试结束会 truncate 整张 users 表。若开启 t.Parallel(),并发子测试会在 // 同一张表上同时插入相同唯一键导致"邮箱已注册"误报;故顺序执行,使每个子测试独占全表。 db, cleanup := newTestDB(t) ctx := context.Background() usersStore := NewUsersStore(db) for _, tc := range []struct { name string test func(t *testing.T, ctx context.Context, db *users) }{ {"Create", testUsersCreate}, {"GetByID", testUsersGetByID}, {"GetByEmail", testUsersGetByEmail}, {"GetByDomain", testUsersGetByDomain}, {"Update", testUsersUpdate}, {"UpdateHarassmentType", testUsersUpdateHarassmentType}, {"UpdateBlockWords", testUsersUpdateBlockWords}, {"Authenticate", testUsersAuthenticate}, {"ChangePassword", testUsersChangePassword}, {"UpdatePassword", testUsersUpdatePassword}, {"Deactivate", testUsersDeactivate}, } { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { if err := cleanup("users"); err != nil { t.Fatal(err) } }) tc.test(t, ctx, usersStore.(*users)) }) } } func testUsersCreate(t *testing.T, ctx context.Context, db *users) { t.Run("normal", func(t *testing.T) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) }) t.Run("repeat email", func(t *testing.T) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99p1ant", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Equal(t, ErrDuplicateEmail, err) }) t.Run("repeat domain", func(t *testing.T) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "e99@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Equal(t, ErrDuplicateDomain, err) }) } func testUsersGetByID(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { got, err := db.GetByID(ctx, 1) require.Nil(t, err) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} want := &User{ Model: gorm.Model{ ID: 1, }, UID: got.UID, Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } requireUserEqual(t, want, got) }) t.Run("not found", func(t *testing.T) { _, err := db.GetByID(ctx, 404) require.Equal(t, ErrUserNotExists, err) }) } func testUsersGetByEmail(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { got, err := db.GetByEmail(ctx, "i@github.red") require.Nil(t, err) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} want := &User{ Model: gorm.Model{ ID: 1, }, UID: got.UID, Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } requireUserEqual(t, want, got) }) t.Run("not found", func(t *testing.T) { _, err := db.GetByEmail(ctx, "404") require.Equal(t, ErrUserNotExists, err) }) } func testUsersGetByDomain(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { got, err := db.GetByDomain(ctx, "e99") require.Nil(t, err) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} want := &User{ Model: gorm.Model{ ID: 1, }, UID: got.UID, Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } requireUserEqual(t, want, got) }) t.Run("not found", func(t *testing.T) { _, err := db.GetByDomain(ctx, "404") require.Equal(t, ErrUserNotExists, err) }) } func testUsersUpdate(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { err := db.Update(ctx, 1, UpdateUserOptions{ Name: "e99", Avatar: "new_avatar.png", Background: "new_background.png", Intro: "Be cool, but also be warm!!", Notify: NotifyTypeNone, }) require.Nil(t, err) got, err := db.GetByID(ctx, 1) require.Nil(t, err) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} want := &User{ Model: gorm.Model{ ID: 1, }, UID: got.UID, Name: "e99", Password: "super_secret", Email: "i@github.red", Avatar: "new_avatar.png", Domain: "e99", Background: "new_background.png", Intro: "Be cool, but also be warm!!", Notify: NotifyTypeNone, } requireUserEqual(t, want, got) }) } func testUsersUpdateHarassmentType(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { err := db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly) require.Nil(t, err) got, err := db.GetByID(ctx, 1) require.Nil(t, err) require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting) // 只应更新类型,屏蔽词列不受影响 require.Equal(t, "", got.BlockWords) }) t.Run("unexpected harassment setting", func(t *testing.T) { err := db.UpdateHarassmentType(ctx, 1, HarassmentSettingType("not found")) require.NotNil(t, err) }) } func testUsersUpdateBlockWords(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { // 先显式设定防骚扰类型,验证 UpdateBlockWords 不会误改该列(类型列默认为空串, // 与 HarassmentSettingNone 等价但值不同,故显式设定后再断言更可靠)。 require.Nil(t, db.UpdateHarassmentType(ctx, 1, HarassmentSettingTypeRegisterOnly)) err := db.UpdateBlockWords(ctx, 1, "广告,抽奖") require.Nil(t, err) got, err := db.GetByID(ctx, 1) require.Nil(t, err) require.Equal(t, "广告,抽奖", got.BlockWords) // 只应更新屏蔽词,类型列不受影响 require.Equal(t, HarassmentSettingTypeRegisterOnly, got.HarassmentSetting) }) t.Run("empty clears words", func(t *testing.T) { err := db.UpdateBlockWords(ctx, 1, "") require.Nil(t, err) got, err := db.GetByID(ctx, 1) require.Nil(t, err) require.Equal(t, "", got.BlockWords) }) } func testUsersAuthenticate(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) got, err := db.Authenticate(ctx, "i@github.red", "super_secret") require.Nil(t, err) got.CreatedAt = time.Time{} got.UpdatedAt = time.Time{} want := &User{ Model: gorm.Model{ ID: 1, }, UID: got.UID, Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", Notify: NotifyTypeEmail, } requireUserEqual(t, want, got) } func testUsersChangePassword(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { err := db.ChangePassword(ctx, 1, "super_secret", "new_password") require.Nil(t, err) }) t.Run("wrong password", func(t *testing.T) { err := db.ChangePassword(ctx, 1, "wrong_password", "new_password") require.Equal(t, ErrBadCredential, err) }) } func testUsersUpdatePassword(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "i@github.red", Avatar: "avater.png", Domain: "e99", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { err := db.UpdatePassword(ctx, 1, "new_password") require.Nil(t, err) }) } func testUsersDeactivate(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "deactivate@github.red", Avatar: "avater.png", Domain: "deactivate", Background: "background.png", Intro: "Be cool, but also be warm.", }) require.Nil(t, err) t.Run("normal", func(t *testing.T) { err := db.Deactivate(ctx, 1) require.Nil(t, err) }) t.Run("not found", func(t *testing.T) { err := db.Deactivate(ctx, 404) require.NotNil(t, err) }) } // TestHarassmentSettingNormalize 覆盖防骚扰设置取值归一: // 新建用户即写入规范值 none;历史空值经启动期归一(normalizeHarassmentSetting)后也为 none。 func TestHarassmentSettingNormalize(t *testing.T) { db, _ := newTestDB(t) ctx := context.Background() usersStore := NewUsersStore(db) require.Nil(t, usersStore.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "normalize@github.red", Avatar: "avater.png", Domain: "normalize", Background: "background.png", Intro: "Be cool, but also be warm.", })) got, err := usersStore.GetByID(ctx, 1) require.Nil(t, err) require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "新建用户应为规范值 none") // 模拟历史行:直接把列置为空串,再跑一次启动期归一。 require.Nil(t, db.Exec("UPDATE users SET harassment_setting = '' WHERE id = 1").Error) require.Nil(t, normalizeHarassmentSetting(db)) got, err = usersStore.GetByID(ctx, 1) require.Nil(t, err) require.Equal(t, HarassmentSettingNone, got.HarassmentSetting, "历史空值应被归一为 none") } // TestDeactivatePurgesUser 覆盖自助停用的删除语义。 // // 停用 = 清空内容 + 保留用户行作「身份占位」(软删除),因此: // - 该账号的提问等内容必须被物理删除; // - 用户行必须仍在(Unscoped 可见,deleted_at 有效),邮箱/域名继续被占用 → 防止他人抢占; // - 用同一邮箱/域名重新注册必须得到「账号已停用」的明确错误,而不是底层的唯一键 500; // - 站长可通过 Reactivate 把账号恢复成空账号。 func TestDeactivatePurgesUser(t *testing.T) { db, cleanup := newTestDB(t) defer func() { _ = cleanup("users", "questions", "feature_controls") }() ctx := context.Background() usersStore := NewUsersStore(db) questionsStore := NewQuestionsStore(db) opts := CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "purge@github.red", Avatar: "avater.png", Domain: "purge", Background: "background.png", Intro: "Be cool, but also be warm.", } require.Nil(t, usersStore.Create(ctx, opts)) user, err := usersStore.GetByEmail(ctx, opts.Email) require.Nil(t, err) _, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"}) require.Nil(t, err) require.Nil(t, usersStore.Deactivate(ctx, user.ID)) var n int64 // 身份占位必须保留(Unscoped 可见),且处于软删除状态 var placeholder User require.Nil(t, db.WithContext(ctx).Unscoped().Where("email = ?", opts.Email).First(&placeholder).Error) require.True(t, placeholder.DeletedAt.Valid, "停用后用户行应保留为软删除的身份占位") require.Equal(t, user.ID, placeholder.ID) require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error) require.EqualValues(t, 0, n, "停用后该用户的提问应被物理删除") // 普通查询已看不见该占位行 _, err = usersStore.GetByEmail(ctx, opts.Email) require.Equal(t, ErrUserNotExists, err) // 同一邮箱 / 域名重新注册:给出明确的「已停用」错误,而不是底层唯一键冲突 err = usersStore.Create(ctx, opts) require.True(t, errors.Is(err, ErrDeactivatedEmail), "同邮箱重注册应返回 ErrDeactivatedEmail,实际: %v", err) require.False(t, errors.Is(err, ErrDuplicateEmail), "不应退化成普通重复邮箱错误") err = usersStore.Create(ctx, CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "another@github.red", Avatar: "avater.png", Domain: opts.Domain, Background: "background.png", Intro: "Be cool, but also be warm.", }) require.True(t, errors.Is(err, ErrDeactivatedDomain), "同域名重注册应返回 ErrDeactivatedDomain,实际: %v", err) // 站长恢复:占位行清掉 deleted_at,账号重新可见 require.Nil(t, usersStore.Reactivate(ctx, user.ID)) restored, err := usersStore.GetByEmail(ctx, opts.Email) require.Nil(t, err) require.Equal(t, user.ID, restored.ID) require.False(t, restored.DeletedAt.Valid) // 恢复后该邮箱已被活跃账号占用 → 普通重复错误 require.True(t, errors.Is(usersStore.Create(ctx, opts), ErrDuplicateEmail)) // 未被停用的账号无法重复恢复 require.Equal(t, ErrUserNotExists, usersStore.Reactivate(ctx, user.ID)) } // TestBanUserAndCleanupRemovesRow 封禁与停用的唯一差别:封禁连用户行一起物理删除, // 该邮箱/域名被彻底释放。 func TestDeactivatePurgesAllOwnedContent(t *testing.T) { db, _ := newTestDB(t) ctx := context.Background() usersStore := NewUsersStore(db) createUser := func(email, domain string) *User { require.Nil(t, usersStore.Create(ctx, CreateUserOptions{ Name: domain, Password: "test-only-password", Email: email, Domain: domain, })) user, err := usersStore.GetByEmail(ctx, email) require.Nil(t, err) return user } owner := createUser("purge-all@example.test", "purge-all") other := createUser("purge-other@example.test", "purge-other") question, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: owner.ID, Content: "owned"}) require.Nil(t, err) otherQuestion, err := NewQuestionsStore(db).Create(ctx, CreateQuestionOptions{UserID: other.ID, Content: "other"}) require.Nil(t, err) images := NewUploadImagesStore(db) ownedImage, err := images.Create(ctx, CreateUploadImageOptions{ QuestionID: question.ID, UploaderUserID: owner.ID, Key: "owned.webp", }) require.Nil(t, err) sharedImage, err := images.Create(ctx, CreateUploadImageOptions{ QuestionID: question.ID, UploaderUserID: owner.ID, Key: "shared.webp", }) require.Nil(t, err) require.Nil(t, db.WithContext(ctx).Create(&UploadImageQuestion{ UploadImageID: sharedImage.ID, QuestionID: otherQuestion.ID, }).Error) orphanImage := &UploadImage{UploaderUserID: owner.ID, Key: "orphan.webp"} require.Nil(t, db.WithContext(ctx).Create(orphanImage).Error) otherImage, err := images.Create(ctx, CreateUploadImageOptions{ QuestionID: otherQuestion.ID, UploaderUserID: other.ID, Key: "other.webp", }) require.Nil(t, err) conversations := NewConversationStore(db) conversation, message, err := conversations.CreateWithFirstMessage(ctx, CreateConversationOptions{ OwnerID: owner.ID, AskerID: other.ID, MaxMessages: 20, }, other.ID, "private") require.Nil(t, err) _, err = NewMessageImageStore(db).Create(ctx, CreateMessageImageOptions{ MessageID: message.ID, URL: "message.webp", FileName: "message.webp", }) require.Nil(t, err) vote, err := NewVotesStore(db).Create(ctx, CreateVoteOptions{ UserID: owner.ID, Title: "owned vote", Type: VoteTypeForm, }) require.Nil(t, err) option, err := NewVoteOptionsStore(db).Create(ctx, CreateVoteOptionOptions{ VoteID: vote.ID, Content: "option", }) require.Nil(t, err) _, err = NewVoteFormFieldsStore(db).Create(ctx, CreateVoteFormFieldOptions{ VoteID: vote.ID, FieldType: VoteFormFieldTypeText, Label: "field", }) require.Nil(t, err) _, err = NewVoteSubmissionsStore(db).Create(ctx, CreateVoteSubmissionOptions{ VoteID: vote.ID, OptionID: option.ID, }) require.Nil(t, err) require.Nil(t, NewBoxViewsStore(db).Record(ctx, RecordBoxViewOptions{ UserID: owner.ID, ViewedOn: time.Now(), Visitor: "visitor", })) require.Nil(t, usersStore.Deactivate(ctx, owner.ID)) assertCount := func(model interface{}, query string, args []interface{}, want int64) { t.Helper() var count int64 require.Nil(t, db.WithContext(ctx).Unscoped().Model(model).Where(query, args...).Count(&count).Error) require.Equal(t, want, count) } assertCount(&MessageImage{}, "message_id = ?", []interface{}{message.ID}, 0) assertCount(&Message{}, "conversation_id = ?", []interface{}{conversation.ID}, 0) assertCount(&Conversation{}, "id = ?", []interface{}{conversation.ID}, 0) assertCount(&VoteSubmission{}, "vote_id = ?", []interface{}{vote.ID}, 0) assertCount(&VoteFormField{}, "vote_id = ?", []interface{}{vote.ID}, 0) assertCount(&VoteOption{}, "vote_id = ?", []interface{}{vote.ID}, 0) assertCount(&Vote{}, "id = ?", []interface{}{vote.ID}, 0) assertCount(&UploadImage{}, "id = ?", []interface{}{ownedImage.ID}, 0) assertCount(&UploadImage{}, "id = ?", []interface{}{orphanImage.ID}, 0) assertCount(&UploadImage{}, "id = ?", []interface{}{sharedImage.ID}, 1) assertCount(&UploadImage{}, "id = ?", []interface{}{otherImage.ID}, 1) assertCount(&UploadImageQuestion{}, "question_id = ?", []interface{}{question.ID}, 0) assertCount(&UploadImageQuestion{}, "upload_image_id = ? AND question_id = ?", []interface{}{sharedImage.ID, otherQuestion.ID}, 1) assertCount(&BoxView{}, "user_id = ?", []interface{}{owner.ID}, 0) assertCount(&Question{}, "id = ?", []interface{}{otherQuestion.ID}, 1) } func TestBanUserAndCleanupRemovesRow(t *testing.T) { db, cleanup := newTestDB(t) defer func() { _ = cleanup("users", "questions", "feature_controls") }() ctx := context.Background() usersStore := NewUsersStore(db) questionsStore := NewQuestionsStore(db) opts := CreateUserOptions{ Name: "E99p1ant", Password: "super_secret", Email: "ban@github.red", Avatar: "avater.png", Domain: "ban", Background: "background.png", Intro: "Be cool, but also be warm.", } require.Nil(t, usersStore.Create(ctx, opts)) user, err := usersStore.GetByEmail(ctx, opts.Email) require.Nil(t, err) _, err = questionsStore.Create(ctx, CreateQuestionOptions{UserID: user.ID, Content: "hello"}) require.Nil(t, err) require.Nil(t, usersStore.BanUserAndCleanup(ctx, user.ID)) var n int64 require.Nil(t, db.WithContext(ctx).Unscoped().Model(&User{}).Where("email = ?", opts.Email).Count(&n).Error) require.EqualValues(t, 0, n, "封禁后用户行应被物理删除") require.Nil(t, db.WithContext(ctx).Unscoped().Model(&Question{}).Where("user_id = ?", user.ID).Count(&n).Error) require.EqualValues(t, 0, n, "封禁后该用户的提问应被物理删除") require.Nil(t, usersStore.Create(ctx, opts), "封禁后应能用同一邮箱重新注册") }