idempotency_repo_integration_test.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // hashedTestValue returns a unique SHA-256 hex string (64 chars) that fits VARCHAR(64) columns.
  13. func hashedTestValue(t *testing.T, prefix string) string {
  14. t.Helper()
  15. sum := sha256.Sum256([]byte(uniqueTestValue(t, prefix)))
  16. return hex.EncodeToString(sum[:])
  17. }
  18. func TestIdempotencyRepo_CreateProcessing_CompeteSameKey(t *testing.T) {
  19. tx := testTx(t)
  20. repo := &idempotencyRepository{sql: tx}
  21. ctx := context.Background()
  22. now := time.Now().UTC()
  23. record := &service.IdempotencyRecord{
  24. Scope: uniqueTestValue(t, "idem-scope-create"),
  25. IdempotencyKeyHash: hashedTestValue(t, "idem-hash"),
  26. RequestFingerprint: hashedTestValue(t, "idem-fp"),
  27. Status: service.IdempotencyStatusProcessing,
  28. LockedUntil: ptrTime(now.Add(30 * time.Second)),
  29. ExpiresAt: now.Add(24 * time.Hour),
  30. }
  31. owner, err := repo.CreateProcessing(ctx, record)
  32. require.NoError(t, err)
  33. require.True(t, owner)
  34. require.NotZero(t, record.ID)
  35. duplicate := &service.IdempotencyRecord{
  36. Scope: record.Scope,
  37. IdempotencyKeyHash: record.IdempotencyKeyHash,
  38. RequestFingerprint: hashedTestValue(t, "idem-fp-other"),
  39. Status: service.IdempotencyStatusProcessing,
  40. LockedUntil: ptrTime(now.Add(30 * time.Second)),
  41. ExpiresAt: now.Add(24 * time.Hour),
  42. }
  43. owner, err = repo.CreateProcessing(ctx, duplicate)
  44. require.NoError(t, err)
  45. require.False(t, owner, "same scope+key hash should be de-duplicated")
  46. }
  47. func TestIdempotencyRepo_TryReclaim_StatusAndLockWindow(t *testing.T) {
  48. tx := testTx(t)
  49. repo := &idempotencyRepository{sql: tx}
  50. ctx := context.Background()
  51. now := time.Now().UTC()
  52. record := &service.IdempotencyRecord{
  53. Scope: uniqueTestValue(t, "idem-scope-reclaim"),
  54. IdempotencyKeyHash: hashedTestValue(t, "idem-hash-reclaim"),
  55. RequestFingerprint: hashedTestValue(t, "idem-fp-reclaim"),
  56. Status: service.IdempotencyStatusProcessing,
  57. LockedUntil: ptrTime(now.Add(10 * time.Second)),
  58. ExpiresAt: now.Add(24 * time.Hour),
  59. }
  60. owner, err := repo.CreateProcessing(ctx, record)
  61. require.NoError(t, err)
  62. require.True(t, owner)
  63. require.NoError(t, repo.MarkFailedRetryable(
  64. ctx,
  65. record.ID,
  66. "RETRYABLE_FAILURE",
  67. now.Add(-2*time.Second),
  68. now.Add(24*time.Hour),
  69. ))
  70. newLockedUntil := now.Add(20 * time.Second)
  71. reclaimed, err := repo.TryReclaim(
  72. ctx,
  73. record.ID,
  74. service.IdempotencyStatusFailedRetryable,
  75. now,
  76. newLockedUntil,
  77. now.Add(24*time.Hour),
  78. )
  79. require.NoError(t, err)
  80. require.True(t, reclaimed, "failed_retryable + expired lock should allow reclaim")
  81. got, err := repo.GetByScopeAndKeyHash(ctx, record.Scope, record.IdempotencyKeyHash)
  82. require.NoError(t, err)
  83. require.NotNil(t, got)
  84. require.Equal(t, service.IdempotencyStatusProcessing, got.Status)
  85. require.NotNil(t, got.LockedUntil)
  86. require.True(t, got.LockedUntil.After(now))
  87. require.NoError(t, repo.MarkFailedRetryable(
  88. ctx,
  89. record.ID,
  90. "RETRYABLE_FAILURE",
  91. now.Add(20*time.Second),
  92. now.Add(24*time.Hour),
  93. ))
  94. reclaimed, err = repo.TryReclaim(
  95. ctx,
  96. record.ID,
  97. service.IdempotencyStatusFailedRetryable,
  98. now,
  99. now.Add(40*time.Second),
  100. now.Add(24*time.Hour),
  101. )
  102. require.NoError(t, err)
  103. require.False(t, reclaimed, "within lock window should not reclaim")
  104. }
  105. func TestIdempotencyRepo_StatusTransition_ToSucceeded(t *testing.T) {
  106. tx := testTx(t)
  107. repo := &idempotencyRepository{sql: tx}
  108. ctx := context.Background()
  109. now := time.Now().UTC()
  110. record := &service.IdempotencyRecord{
  111. Scope: uniqueTestValue(t, "idem-scope-success"),
  112. IdempotencyKeyHash: hashedTestValue(t, "idem-hash-success"),
  113. RequestFingerprint: hashedTestValue(t, "idem-fp-success"),
  114. Status: service.IdempotencyStatusProcessing,
  115. LockedUntil: ptrTime(now.Add(10 * time.Second)),
  116. ExpiresAt: now.Add(24 * time.Hour),
  117. }
  118. owner, err := repo.CreateProcessing(ctx, record)
  119. require.NoError(t, err)
  120. require.True(t, owner)
  121. require.NoError(t, repo.MarkSucceeded(ctx, record.ID, 200, `{"ok":true}`, now.Add(24*time.Hour)))
  122. got, err := repo.GetByScopeAndKeyHash(ctx, record.Scope, record.IdempotencyKeyHash)
  123. require.NoError(t, err)
  124. require.NotNil(t, got)
  125. require.Equal(t, service.IdempotencyStatusSucceeded, got.Status)
  126. require.NotNil(t, got.ResponseStatus)
  127. require.Equal(t, 200, *got.ResponseStatus)
  128. require.NotNil(t, got.ResponseBody)
  129. require.Equal(t, `{"ok":true}`, *got.ResponseBody)
  130. require.Nil(t, got.LockedUntil)
  131. }