| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172 |
- package dedupe
- import (
- "context"
- "testing"
- "time"
- "github.com/redis/go-redis/v9"
- "git3.techno-world.net/lrosales/broad-announce/internal/store"
- )
- // These tests need a real Redis. They use a short window and a
- // test-prefix so they don't collide with prod data.
- //
- // In CI: run with `go test -tags=redis ./...` (the integration
- // build tag) and ensure Redis is on localhost:6379.
- //
- // In the M0 docker-compose, `make test-redis` runs the same suite
- // against the dev container.
- func newRedis(t *testing.T) *store.Redis {
- t.Helper()
- r, err := store.ConnectRedis(context.Background(), "redis://localhost:6379/0")
- if err != nil {
- t.Skipf("redis not available, skipping: %v", err)
- }
- return r
- }
- func TestCheck_FirstAndDuplicate(t *testing.T) {
- r := newRedis(t)
- d := New(r.Client, 2*time.Second)
- isNew, n, err := d.Check(context.Background(), "src-A", "key-1")
- if err != nil {
- t.Fatal(err)
- }
- if !isNew || n != 1 {
- t.Fatalf("first: want new/1, got new=%v n=%d", isNew, n)
- }
- isNew, n, err = d.Check(context.Background(), "src-A", "key-1")
- if err != nil {
- t.Fatal(err)
- }
- if isNew || n != 2 {
- t.Fatalf("second: want dup/2, got new=%v n=%d", isNew, n)
- }
- }
- func TestCheck_DifferentSourcesDoNotCollide(t *testing.T) {
- r := newRedis(t)
- d := New(r.Client, 2*time.Second)
- if isNew, _, _ := d.Check(context.Background(), "src-A", "shared"); !isNew {
- t.Fatal("src-A first should be new")
- }
- if isNew, _, _ := d.Check(context.Background(), "src-B", "shared"); !isNew {
- t.Fatal("src-B first should be new (different source)")
- }
- }
- func TestCheck_EmptyDedupeKeyIsNeverDeduped(t *testing.T) {
- r := newRedis(t)
- d := New(r.Client, 2*time.Second)
- for i := 0; i < 5; i++ {
- isNew, n, err := d.Check(context.Background(), "src-A", "")
- if err != nil {
- t.Fatal(err)
- }
- if !isNew || n != 1 {
- t.Fatalf("empty dedupe_key should always be new, got new=%v n=%d", isNew, n)
- }
- }
- }
- func TestCheck_WindowExpires(t *testing.T) {
- r := newRedis(t)
- d := New(r.Client, 500*time.Millisecond)
- // Use a unique key per run so we don't see state from a
- // previous test that used a longer default window.
- key := "expiring-" + time.Now().Format("150405.000000000")
- if isNew, _, _ := d.Check(context.Background(), "src-A", key); !isNew {
- t.Fatal("first should be new")
- }
- // The Lua script rounds sub-second windows up to 1s via
- // math.ceil, so we need to wait > 1s for the TTL to
- // actually elapse.
- time.Sleep(1200 * time.Millisecond)
- isNew, n, err := d.Check(context.Background(), "src-A", key)
- if err != nil {
- t.Fatal(err)
- }
- if !isNew || n != 1 {
- t.Fatalf("after window: want new/1, got new=%v n=%d", isNew, n)
- }
- }
- // M6: sliding window keeps the key alive when duplicates keep
- // arriving. With a 500ms window, if a duplicate hits at 300ms
- // (within the window) the TTL is refreshed from that point,
- // and a third hit at 700ms is still a duplicate (window was
- // pushed out to 1.2s by the second hit). This is the M6
- // contract that lets operators see (×N) for arbitrarily long
- // alert storms.
- func TestCheck_SlidingWindowKeepsAlive(t *testing.T) {
- r := newRedis(t)
- d := New(r.Client, 500*time.Millisecond)
- key := "storm-" + time.Now().Format("150405.000000000")
- if isNew, _, _ := d.Check(context.Background(), "src-A", key); !isNew {
- t.Fatal("first should be new")
- }
- // Three duplicates, 200ms apart — every one within the
- // 500ms window from the *previous* observation.
- for i := 0; i < 3; i++ {
- time.Sleep(200 * time.Millisecond)
- isNew, n, err := d.Check(context.Background(), "src-A", key)
- if err != nil {
- t.Fatal(err)
- }
- if isNew {
- t.Fatalf("dupe %d should be dup, got new", i+1)
- }
- want := uint32(i + 2) // 2, 3, 4
- if n != want {
- t.Fatalf("dupe %d: want n=%d, got n=%d", i+1, want, n)
- }
- }
- // After the stream stops, wait for the window to expire
- // and the next arrival should be a fresh "new".
- // The Lua script rounds sub-second windows up to 1s.
- time.Sleep(1300 * time.Millisecond)
- isNew, n, err := d.Check(context.Background(), "src-A", key)
- if err != nil {
- t.Fatal(err)
- }
- if !isNew || n != 1 {
- t.Fatalf("after stream ends: want new/1, got new=%v n=%d", isNew, n)
- }
- }
- // M6: a high-volume burst (1000 hits) all share the same
- // key. The first is new, the next 999 are dupes with
- // monotonically increasing counts. The TTL refreshes on
- // every hit, so the key stays alive throughout.
- func TestCheck_BurstOfThousand(t *testing.T) {
- r := newRedis(t)
- d := New(r.Client, 2*time.Second)
- key := "burst-" + time.Now().Format("150405.000000000")
- isNew, n, _ := d.Check(context.Background(), "src-A", key)
- if !isNew || n != 1 {
- t.Fatalf("first: want new/1, got new=%v n=%d", isNew, n)
- }
- for i := 2; i <= 1000; i++ {
- isNew, n, err := d.Check(context.Background(), "src-A", key)
- if err != nil {
- t.Fatalf("hit %d err: %v", i, err)
- }
- if isNew {
- t.Fatalf("hit %d: want dup, got new", i)
- }
- if n != uint32(i) {
- t.Fatalf("hit %d: want n=%d, got n=%d", i, i, n)
- }
- }
- }
- // Sanity: ensure the package compiles when redis isn't around.
- var _ = redis.Nil
|