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