user_repo_delete_atomicity_integration_test.go 4.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "fmt"
  6. "testing"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // TestUserRepository_DeleteUser_AtomicWithAPIKeys 复现 AdminService.DeleteUser 的事务编排场景:
  13. // 把"tombstone 并删 API Key"(apiKeyRepo.DeleteWithAudit) 与"删 User"(userRepo.Delete) 放进同一个外部事务时,
  14. // userRepo.Delete 必须复用 context 中的事务,而不是用 base client 自起一个独立事务并提前提交。
  15. //
  16. // 用例用"回滚外层事务"来模拟 commit 失败 / 中止:
  17. // - 修复前:userRepo.Delete 用 base client 自起独立事务并 commit,回滚外层事务后用户仍被删除,
  18. // 而 API Key 随外层事务回滚 → Case 1 断言失败,暴露原子性缺陷(即 issue #3021 的不可恢复状态)。
  19. // - 修复后:两者落在同一事务,回滚后用户与 API Key 一起恢复。
  20. //
  21. // 关键点:repo 必须用 base client 构造(NewUserRepository/NewAPIKeyRepository),并由本测试手动
  22. // 开启外层事务,这与生产环境 wire 注入的方式一致;不能复用 APIKeyRepoSuite 的 testEntTx
  23. // (那会让 repo 持有 tx client,走的是另一条 ErrTxStarted 复用路径,无法覆盖本场景)。
  24. func TestUserRepository_DeleteUser_AtomicWithAPIKeys(t *testing.T) {
  25. ctx := context.Background()
  26. client := testEntClient(t)
  27. userRepo := NewUserRepository(client, integrationDB)
  28. apiKeyRepo := NewAPIKeyRepository(client, integrationDB)
  29. // 已提交的初始数据:1 个用户 + 2 个 active API Key。
  30. user := mustCreateUser(t, client, &service.User{})
  31. key1 := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: fmt.Sprintf("sk-atomic-a-%d", user.ID)})
  32. key2 := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: fmt.Sprintf("sk-atomic-b-%d", user.ID)})
  33. t.Cleanup(func() {
  34. // testEntClient 的写入不会自动回滚,best-effort 清理避免污染共享库。
  35. _, _ = integrationDB.Exec(`DELETE FROM deleted_api_key_audits WHERE user_id = $1`, user.ID)
  36. _, _ = integrationDB.Exec(`DELETE FROM api_keys WHERE user_id = $1`, user.ID)
  37. _, _ = integrationDB.Exec(`DELETE FROM users WHERE id = $1`, user.ID)
  38. })
  39. listParams := pagination.PaginationParams{Page: 1, PageSize: 10}
  40. // --- Case 1: 外层事务回滚 → 删 Key 与删 User 必须一起回滚 ---
  41. tx, err := client.Tx(ctx)
  42. require.NoError(t, err, "begin outer tx")
  43. opCtx := dbent.NewTxContext(ctx, tx)
  44. require.NoError(t, apiKeyRepo.DeleteWithAudit(opCtx, key1.ID))
  45. require.NoError(t, apiKeyRepo.DeleteWithAudit(opCtx, key2.ID))
  46. require.NoError(t, userRepo.Delete(opCtx, user.ID))
  47. require.NoError(t, tx.Rollback(), "rollback outer tx (模拟 commit 失败/中止)")
  48. // 已提交视图:不应有任何写入"逃逸"到独立已提交事务。
  49. gotUser, err := userRepo.GetByID(ctx, user.ID)
  50. require.NoError(t, err, "回滚后用户必须仍然存在(未被独立事务提前删除)")
  51. require.Equal(t, user.ID, gotUser.ID)
  52. keys, _, err := apiKeyRepo.ListByUserID(ctx, user.ID, listParams, service.APIKeyListFilters{})
  53. require.NoError(t, err, "ListByUserID")
  54. require.Len(t, keys, 2, "回滚后 2 个 API Key 必须仍为 active")
  55. var auditCount int
  56. require.NoError(t, integrationDB.QueryRowContext(ctx,
  57. `SELECT COUNT(*) FROM deleted_api_key_audits WHERE user_id = $1`, user.ID).Scan(&auditCount))
  58. require.Zero(t, auditCount, "回滚后不应有已提交的审计行")
  59. // --- Case 2: 外层事务提交 → 删 Key 与删 User 一起生效 ---
  60. tx2, err := client.Tx(ctx)
  61. require.NoError(t, err, "begin outer tx #2")
  62. opCtx2 := dbent.NewTxContext(ctx, tx2)
  63. require.NoError(t, apiKeyRepo.DeleteWithAudit(opCtx2, key1.ID))
  64. require.NoError(t, apiKeyRepo.DeleteWithAudit(opCtx2, key2.ID))
  65. require.NoError(t, userRepo.Delete(opCtx2, user.ID))
  66. require.NoError(t, tx2.Commit(), "commit outer tx")
  67. _, err = userRepo.GetByID(ctx, user.ID)
  68. require.Error(t, err, "提交后用户应被软删除,GetByID 应返回未找到")
  69. keysAfter, _, err := apiKeyRepo.ListByUserID(ctx, user.ID, listParams, service.APIKeyListFilters{})
  70. require.NoError(t, err, "ListByUserID")
  71. require.Empty(t, keysAfter, "提交后 API Key 应全部被软删除")
  72. require.NoError(t, integrationDB.QueryRowContext(ctx,
  73. `SELECT COUNT(*) FROM deleted_api_key_audits WHERE user_id = $1`, user.ID).Scan(&auditCount))
  74. require.Zero(t, auditCount, "提交后也不得保留被删 Key 的凭据材料")
  75. }