user_repo_email_lookup_unit_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. package repository
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "sync"
  7. "testing"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. "github.com/Wei-Shaw/sub2api/ent/enttest"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/stretchr/testify/require"
  13. "entgo.io/ent/dialect"
  14. entsql "entgo.io/ent/dialect/sql"
  15. _ "modernc.org/sqlite"
  16. )
  17. func newUserEntRepo(t *testing.T) (*userRepository, *dbent.Client) {
  18. t.Helper()
  19. db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", t.Name()))
  20. require.NoError(t, err)
  21. t.Cleanup(func() { _ = db.Close() })
  22. db.SetMaxOpenConns(10)
  23. _, err = db.Exec("PRAGMA foreign_keys = ON")
  24. require.NoError(t, err)
  25. drv := entsql.OpenDB(dialect.SQLite, db)
  26. client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
  27. t.Cleanup(func() { _ = client.Close() })
  28. return newUserRepositoryWithSQL(client, db), client
  29. }
  30. func TestUserRepositoryGetByEmailNormalizesLegacySpacingAndCase(t *testing.T) {
  31. repo, _ := newUserEntRepo(t)
  32. ctx := context.Background()
  33. err := repo.Create(ctx, &service.User{
  34. Email: " Legacy@Example.com ",
  35. Username: "legacy-user",
  36. PasswordHash: "hash",
  37. Role: service.RoleUser,
  38. Status: service.StatusActive,
  39. })
  40. require.NoError(t, err)
  41. got, err := repo.GetByEmail(ctx, "legacy@example.com")
  42. require.NoError(t, err)
  43. require.Equal(t, " Legacy@Example.com ", got.Email)
  44. }
  45. func TestUserRepositoryExistsByEmailNormalizesLegacySpacingAndCase(t *testing.T) {
  46. repo, _ := newUserEntRepo(t)
  47. ctx := context.Background()
  48. err := repo.Create(ctx, &service.User{
  49. Email: " Legacy@Example.com ",
  50. Username: "legacy-user",
  51. PasswordHash: "hash",
  52. Role: service.RoleUser,
  53. Status: service.StatusActive,
  54. })
  55. require.NoError(t, err)
  56. exists, err := repo.ExistsByEmail(ctx, " LEGACY@example.com ")
  57. require.NoError(t, err)
  58. require.True(t, exists)
  59. }
  60. func TestUserRepositoryCreateRejectsNormalizedEmailDuplicate(t *testing.T) {
  61. repo, _ := newUserEntRepo(t)
  62. ctx := context.Background()
  63. err := repo.Create(ctx, &service.User{
  64. Email: " Existing@Example.com ",
  65. Username: "existing-user",
  66. PasswordHash: "hash",
  67. Role: service.RoleUser,
  68. Status: service.StatusActive,
  69. })
  70. require.NoError(t, err)
  71. err = repo.Create(ctx, &service.User{
  72. Email: "existing@example.com",
  73. Username: "duplicate-user",
  74. PasswordHash: "hash",
  75. Role: service.RoleUser,
  76. Status: service.StatusActive,
  77. })
  78. require.ErrorIs(t, err, service.ErrEmailExists)
  79. }
  80. func TestUserRepositoryUpdateRejectsNormalizedEmailDuplicate(t *testing.T) {
  81. repo, _ := newUserEntRepo(t)
  82. ctx := context.Background()
  83. first := &service.User{
  84. Email: " Existing@Example.com ",
  85. Username: "existing-user",
  86. PasswordHash: "hash",
  87. Role: service.RoleUser,
  88. Status: service.StatusActive,
  89. }
  90. require.NoError(t, repo.Create(ctx, first))
  91. second := &service.User{
  92. Email: "second@example.com",
  93. Username: "second-user",
  94. PasswordHash: "hash",
  95. Role: service.RoleUser,
  96. Status: service.StatusActive,
  97. }
  98. require.NoError(t, repo.Create(ctx, second))
  99. second.Email = " existing@example.com "
  100. err := repo.Update(ctx, second, service.UserUpdateFields{Email: true})
  101. require.ErrorIs(t, err, service.ErrEmailExists)
  102. }
  103. func TestUserRepositoryGetByEmailReportsNormalizedEmailConflict(t *testing.T) {
  104. repo, client := newUserEntRepo(t)
  105. ctx := context.Background()
  106. _, err := client.User.Create().
  107. SetEmail("Conflict@Example.com").
  108. SetUsername("conflict-user-1").
  109. SetPasswordHash("hash").
  110. SetRole(service.RoleUser).
  111. SetStatus(service.StatusActive).
  112. Save(ctx)
  113. require.NoError(t, err)
  114. _, err = client.User.Create().
  115. SetEmail(" conflict@example.com ").
  116. SetUsername("conflict-user-2").
  117. SetPasswordHash("hash").
  118. SetRole(service.RoleUser).
  119. SetStatus(service.StatusActive).
  120. Save(ctx)
  121. require.NoError(t, err)
  122. _, err = repo.GetByEmail(ctx, "conflict@example.com")
  123. require.Error(t, err)
  124. require.ErrorContains(t, err, "normalized email lookup matched multiple users")
  125. }
  126. func TestUserRepositoryCreateSerializesNormalizedEmailConflictsUnderConcurrency(t *testing.T) {
  127. repo, client := newUserEntRepo(t)
  128. ctx := context.Background()
  129. firstCreateStarted := make(chan struct{})
  130. releaseFirstCreate := make(chan struct{})
  131. var firstCreate sync.Once
  132. client.User.Use(func(next dbent.Mutator) dbent.Mutator {
  133. return dbent.MutateFunc(func(ctx context.Context, m dbent.Mutation) (dbent.Value, error) {
  134. blocked := false
  135. if m.Op().Is(dbent.OpCreate) {
  136. firstCreate.Do(func() {
  137. blocked = true
  138. close(firstCreateStarted)
  139. })
  140. }
  141. if blocked {
  142. <-releaseFirstCreate
  143. }
  144. return next.Mutate(ctx, m)
  145. })
  146. })
  147. type createResult struct {
  148. err error
  149. }
  150. results := make(chan createResult, 2)
  151. go func() {
  152. results <- createResult{err: repo.Create(ctx, &service.User{
  153. Email: " Race@Example.com ",
  154. Username: "race-user-1",
  155. PasswordHash: "hash",
  156. Role: service.RoleUser,
  157. Status: service.StatusActive,
  158. })}
  159. }()
  160. <-firstCreateStarted
  161. go func() {
  162. results <- createResult{err: repo.Create(ctx, &service.User{
  163. Email: "race@example.com",
  164. Username: "race-user-2",
  165. PasswordHash: "hash",
  166. Role: service.RoleUser,
  167. Status: service.StatusActive,
  168. })}
  169. }()
  170. time.Sleep(100 * time.Millisecond)
  171. close(releaseFirstCreate)
  172. first := <-results
  173. second := <-results
  174. errors := []error{first.err, second.err}
  175. successes := 0
  176. conflicts := 0
  177. for _, err := range errors {
  178. switch err {
  179. case nil:
  180. successes++
  181. case service.ErrEmailExists:
  182. conflicts++
  183. default:
  184. t.Fatalf("unexpected create error: %v", err)
  185. }
  186. }
  187. require.Equal(t, 1, successes)
  188. require.Equal(t, 1, conflicts)
  189. count, err := client.User.Query().Where(userEmailLookupPredicate("race@example.com")).Count(ctx)
  190. require.NoError(t, err)
  191. require.Equal(t, 1, count)
  192. }