dedupe_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. package dedupe
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/redis/go-redis/v9"
  7. "git3.techno-world.net/lrosales/broad-announce/internal/store"
  8. )
  9. // These tests need a real Redis. They use a short window and a
  10. // test-prefix so they don't collide with prod data.
  11. //
  12. // In CI: run with `go test -tags=redis ./...` (the integration
  13. // build tag) and ensure Redis is on localhost:6379.
  14. //
  15. // In the M0 docker-compose, `make test-redis` runs the same suite
  16. // against the dev container.
  17. func newRedis(t *testing.T) *store.Redis {
  18. t.Helper()
  19. r, err := store.ConnectRedis(context.Background(), "redis://localhost:6379/0")
  20. if err != nil {
  21. t.Skipf("redis not available, skipping: %v", err)
  22. }
  23. return r
  24. }
  25. func TestCheck_FirstAndDuplicate(t *testing.T) {
  26. r := newRedis(t)
  27. d := New(r.Client, 2*time.Second)
  28. isNew, n, err := d.Check(context.Background(), "src-A", "key-1")
  29. if err != nil {
  30. t.Fatal(err)
  31. }
  32. if !isNew || n != 1 {
  33. t.Fatalf("first: want new/1, got new=%v n=%d", isNew, n)
  34. }
  35. isNew, n, err = d.Check(context.Background(), "src-A", "key-1")
  36. if err != nil {
  37. t.Fatal(err)
  38. }
  39. if isNew || n != 2 {
  40. t.Fatalf("second: want dup/2, got new=%v n=%d", isNew, n)
  41. }
  42. }
  43. func TestCheck_DifferentSourcesDoNotCollide(t *testing.T) {
  44. r := newRedis(t)
  45. d := New(r.Client, 2*time.Second)
  46. if isNew, _, _ := d.Check(context.Background(), "src-A", "shared"); !isNew {
  47. t.Fatal("src-A first should be new")
  48. }
  49. if isNew, _, _ := d.Check(context.Background(), "src-B", "shared"); !isNew {
  50. t.Fatal("src-B first should be new (different source)")
  51. }
  52. }
  53. func TestCheck_EmptyDedupeKeyIsNeverDeduped(t *testing.T) {
  54. r := newRedis(t)
  55. d := New(r.Client, 2*time.Second)
  56. for i := 0; i < 5; i++ {
  57. isNew, n, err := d.Check(context.Background(), "src-A", "")
  58. if err != nil {
  59. t.Fatal(err)
  60. }
  61. if !isNew || n != 1 {
  62. t.Fatalf("empty dedupe_key should always be new, got new=%v n=%d", isNew, n)
  63. }
  64. }
  65. }
  66. func TestCheck_WindowExpires(t *testing.T) {
  67. r := newRedis(t)
  68. d := New(r.Client, 500*time.Millisecond)
  69. // Use a unique key per run so we don't see state from a
  70. // previous test that used a longer default window.
  71. key := "expiring-" + time.Now().Format("150405.000000000")
  72. if isNew, _, _ := d.Check(context.Background(), "src-A", key); !isNew {
  73. t.Fatal("first should be new")
  74. }
  75. // The Lua script rounds sub-second windows up to 1s via
  76. // math.ceil, so we need to wait > 1s for the TTL to
  77. // actually elapse.
  78. time.Sleep(1200 * time.Millisecond)
  79. isNew, n, err := d.Check(context.Background(), "src-A", key)
  80. if err != nil {
  81. t.Fatal(err)
  82. }
  83. if !isNew || n != 1 {
  84. t.Fatalf("after window: want new/1, got new=%v n=%d", isNew, n)
  85. }
  86. }
  87. // M6: sliding window keeps the key alive when duplicates keep
  88. // arriving. With a 500ms window, if a duplicate hits at 300ms
  89. // (within the window) the TTL is refreshed from that point,
  90. // and a third hit at 700ms is still a duplicate (window was
  91. // pushed out to 1.2s by the second hit). This is the M6
  92. // contract that lets operators see (×N) for arbitrarily long
  93. // alert storms.
  94. func TestCheck_SlidingWindowKeepsAlive(t *testing.T) {
  95. r := newRedis(t)
  96. d := New(r.Client, 500*time.Millisecond)
  97. key := "storm-" + time.Now().Format("150405.000000000")
  98. if isNew, _, _ := d.Check(context.Background(), "src-A", key); !isNew {
  99. t.Fatal("first should be new")
  100. }
  101. // Three duplicates, 200ms apart — every one within the
  102. // 500ms window from the *previous* observation.
  103. for i := 0; i < 3; i++ {
  104. time.Sleep(200 * time.Millisecond)
  105. isNew, n, err := d.Check(context.Background(), "src-A", key)
  106. if err != nil {
  107. t.Fatal(err)
  108. }
  109. if isNew {
  110. t.Fatalf("dupe %d should be dup, got new", i+1)
  111. }
  112. want := uint32(i + 2) // 2, 3, 4
  113. if n != want {
  114. t.Fatalf("dupe %d: want n=%d, got n=%d", i+1, want, n)
  115. }
  116. }
  117. // After the stream stops, wait for the window to expire
  118. // and the next arrival should be a fresh "new".
  119. // The Lua script rounds sub-second windows up to 1s.
  120. time.Sleep(1300 * time.Millisecond)
  121. isNew, n, err := d.Check(context.Background(), "src-A", key)
  122. if err != nil {
  123. t.Fatal(err)
  124. }
  125. if !isNew || n != 1 {
  126. t.Fatalf("after stream ends: want new/1, got new=%v n=%d", isNew, n)
  127. }
  128. }
  129. // M6: a high-volume burst (1000 hits) all share the same
  130. // key. The first is new, the next 999 are dupes with
  131. // monotonically increasing counts. The TTL refreshes on
  132. // every hit, so the key stays alive throughout.
  133. func TestCheck_BurstOfThousand(t *testing.T) {
  134. r := newRedis(t)
  135. d := New(r.Client, 2*time.Second)
  136. key := "burst-" + time.Now().Format("150405.000000000")
  137. isNew, n, _ := d.Check(context.Background(), "src-A", key)
  138. if !isNew || n != 1 {
  139. t.Fatalf("first: want new/1, got new=%v n=%d", isNew, n)
  140. }
  141. for i := 2; i <= 1000; i++ {
  142. isNew, n, err := d.Check(context.Background(), "src-A", key)
  143. if err != nil {
  144. t.Fatalf("hit %d err: %v", i, err)
  145. }
  146. if isNew {
  147. t.Fatalf("hit %d: want dup, got new", i)
  148. }
  149. if n != uint32(i) {
  150. t.Fatalf("hit %d: want n=%d, got n=%d", i, i, n)
  151. }
  152. }
  153. }
  154. // Sanity: ensure the package compiles when redis isn't around.
  155. var _ = redis.Nil