user_repo_invitation_claim_test.go 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "testing"
  6. dbent "github.com/Wei-Shaw/sub2api/ent"
  7. "github.com/Wei-Shaw/sub2api/ent/redeemcode"
  8. "github.com/Wei-Shaw/sub2api/ent/user"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // TestCreateWithEmailAliasGuardJoinsOuterTransaction 验证用户创建会加入调用方开启的
  13. // 外部 ent 事务(注册流程“建用户 + 占用邀请码”原子性的基础):
  14. // - 外层事务回滚后,用户与邀请码占用必须一并撤销(不得残留孤儿账号);
  15. // - 外层事务提交后,用户与邀请码占用同时生效。
  16. //
  17. // 回归背景:此前 create() 通过 r.client.Tx(ctx) 自开事务且自行 Commit —— ent 的
  18. // Client.Tx 不感知上下文事务(只检查 driver 类型),ErrTxStarted 分支实际是死代码,
  19. // 导致外层事务包不住用户写入;并发注册同一邀请码时,败者用户的写入已自行提交,
  20. // 即使注册被拒绝也会留下可登录的孤儿账号(1 个邀请码仍可生成任意数量账号)。
  21. func TestCreateWithEmailAliasGuardJoinsOuterTransaction(t *testing.T) {
  22. client := testEntClient(t)
  23. userRepo := NewUserRepository(client, integrationDB)
  24. redeemRepo := NewRedeemCodeRepository(client)
  25. ctx := context.Background()
  26. // 清理:本测试会真实提交少量数据,确保不影响同包其它集成测试。
  27. var committedUserEmails []string
  28. var seededCodeIDs []int64
  29. t.Cleanup(func() {
  30. if len(committedUserEmails) > 0 {
  31. _, _ = client.User.Delete().Where(user.EmailIn(committedUserEmails...)).Exec(ctx)
  32. }
  33. if len(seededCodeIDs) > 0 {
  34. _, _ = client.RedeemCode.Delete().Where(redeemcode.IDIn(seededCodeIDs...)).Exec(ctx)
  35. }
  36. })
  37. seedCode := func(code string) int64 {
  38. _, err := client.RedeemCode.Create().
  39. SetCode(code).
  40. SetType(service.RedeemTypeInvitation).
  41. SetStatus(service.StatusUnused).
  42. SetValue(0).
  43. Save(ctx)
  44. require.NoError(t, err, "seed redeem code")
  45. c, err := client.RedeemCode.Query().Where(redeemcode.CodeEQ(code)).Only(ctx)
  46. require.NoError(t, err)
  47. seededCodeIDs = append(seededCodeIDs, c.ID)
  48. return c.ID
  49. }
  50. t.Run("rollback removes user and releases claim", func(t *testing.T) {
  51. codeID := seedCode("ITX-RACE-ROLLBACK-001")
  52. tx, err := client.Tx(ctx)
  53. require.NoError(t, err)
  54. txCtx := dbent.NewTxContext(ctx, tx)
  55. u := &service.User{
  56. Email: "itx-rollback@example.com",
  57. PasswordHash: "test-password-hash",
  58. Role: service.RoleUser,
  59. Status: service.StatusActive,
  60. Balance: 0,
  61. Concurrency: 1,
  62. }
  63. require.NoError(t, userRepo.CreateWithEmailAliasGuard(txCtx, u))
  64. require.Greater(t, u.ID, int64(0), "create 应回填用户 ID")
  65. require.NoError(t, redeemRepo.Use(txCtx, codeID, u.ID))
  66. require.NoError(t, tx.Rollback())
  67. exists, err := userRepo.ExistsByEmail(ctx, "itx-rollback@example.com")
  68. require.NoError(t, err)
  69. require.False(t, exists, "回滚后不得残留孤儿用户")
  70. after, err := client.RedeemCode.Get(ctx, codeID)
  71. require.NoError(t, err)
  72. require.Equal(t, service.StatusUnused, after.Status, "回滚后邀请码应保持 unused")
  73. })
  74. t.Run("commit persists user and claim together", func(t *testing.T) {
  75. codeID := seedCode("ITX-RACE-COMMIT-001")
  76. tx, err := client.Tx(ctx)
  77. require.NoError(t, err)
  78. txCtx := dbent.NewTxContext(ctx, tx)
  79. u := &service.User{
  80. Email: "itx-commit@example.com",
  81. PasswordHash: "test-password-hash",
  82. Role: service.RoleUser,
  83. Status: service.StatusActive,
  84. Balance: 0,
  85. Concurrency: 1,
  86. }
  87. require.NoError(t, userRepo.CreateWithEmailAliasGuard(txCtx, u))
  88. require.NoError(t, redeemRepo.Use(txCtx, codeID, u.ID))
  89. require.NoError(t, tx.Commit())
  90. committedUserEmails = append(committedUserEmails, u.Email)
  91. exists, err := userRepo.ExistsByEmail(ctx, "itx-commit@example.com")
  92. require.NoError(t, err)
  93. require.True(t, exists, "提交后用户应存在")
  94. after, err := client.RedeemCode.Get(ctx, codeID)
  95. require.NoError(t, err)
  96. require.Equal(t, service.StatusUsed, after.Status, "提交后邀请码应为 used")
  97. })
  98. }