Skip to content

Commit f0f1e9d

Browse files
committed
Implemented integration tests
1 parent 4f1a2ba commit f0f1e9d

4 files changed

Lines changed: 345 additions & 4 deletions

File tree

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,95 @@
1-
// tests/integration/concurrency_test.go
1+
//go:build integration
2+
3+
// Run with: go test -tags=integration -v ./tests/integration/...
4+
// Requires: live Redis at REDIS_ADDR and service running at SERVICE_ADDR
5+
6+
package integration_test
7+
8+
import (
9+
"context"
10+
"net/http"
11+
"os"
12+
"sync"
13+
"sync/atomic"
14+
"testing"
15+
"time"
16+
)
17+
18+
func serviceAddr() string {
19+
if a := os.Getenv("SERVICE_ADDR"); a != "" {
20+
return a
21+
}
22+
return "http://localhost:8080"
23+
}
24+
25+
// TestConcurrentLimit fires limit+50 goroutines simultaneously against a single API key
26+
// and asserts that no more than `limit` requests were allowed.
27+
//
28+
// This is the primary correctness proof for the distributed atomic decision.
29+
// If the Lua script is not atomic, multiple replicas can race and over-admit.
30+
func TestConcurrentLimit(t *testing.T) {
31+
const limit = 100
32+
const total = limit + 50
33+
34+
var wg sync.WaitGroup
35+
var allowed atomic.Int64
36+
client := &http.Client{Timeout: 5 * time.Second}
37+
38+
for i := 0; i < total; i++ {
39+
wg.Add(1)
40+
go func() {
41+
defer wg.Done()
42+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
43+
serviceAddr()+"/check", nil)
44+
req.Header.Set("X-API-KEY", "test-concurrent-key")
45+
46+
resp, err := client.Do(req)
47+
if err != nil {
48+
t.Logf("Request error: %v", err)
49+
return
50+
}
51+
defer resp.Body.Close()
52+
53+
if resp.StatusCode == http.StatusOK {
54+
allowed.Add(1)
55+
}
56+
}()
57+
}
58+
59+
wg.Wait()
60+
61+
got := int(allowed.Load())
62+
if got > limit {
63+
t.Errorf("Over-admitted: allowed %d requests, limit was %d - Lua atomicity may be broken", got, limit)
64+
}
65+
t.Logf("Result: %d/%d requests allowed (limit: %d)", got, total, limit)
66+
}
67+
68+
func TestWindowBoundary(t *testing.T) {
69+
t.Skip("This test is timing-sensitive and may be flaky; run manually if needed")
70+
}
71+
72+
// TestMultiKeyIsolation verifies that limits for one key do not affect another.
73+
func TestMultiKeyIsolation(t *testing.T) {
74+
client := &http.Client{Timeout: 5 * time.Second}
75+
76+
doRequest := func(key string) int {
77+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
78+
serviceAddr()+"/check", nil)
79+
req.Header.Set("X-API-KEY", key)
80+
resp, err := client.Do(req)
81+
if err != nil {
82+
t.Fatalf("Request failed for key %s: %v", key, err)
83+
}
84+
defer resp.Body.Close()
85+
return resp.StatusCode
86+
}
87+
88+
// Both keys should start fresh
89+
if status := doRequest("isolation-key-a"); status != http.StatusOK {
90+
t.Errorf("Key-a first request: expected 200, got %d", status)
91+
}
92+
if status := doRequest("isolation-key-b"); status != http.StatusOK {
93+
t.Errorf("Key-b first request: expected 200, got %d", status)
94+
}
95+
}
Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,91 @@
1-
// tests/integration/correctness_test.go
1+
//go:build integration
2+
3+
package integration_test
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestExactLimitBoundary(t *testing.T) {
13+
const limit = 100
14+
client := &http.Client{Timeout: 5 * time.Second}
15+
apiKey := "test-boundary-sequential"
16+
17+
for i := 1; i <= limit; i++ {
18+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
19+
serviceAddr()+"/check", nil)
20+
req.Header.Set("X-API-Key", apiKey)
21+
22+
resp, err := client.Do(req)
23+
if err != nil {
24+
t.Fatalf("request %d failed: %v", i, err)
25+
}
26+
resp.Body.Close()
27+
28+
if resp.StatusCode != http.StatusOK {
29+
t.Fatalf("request %d/%d: expected 200, got %d", i, limit, resp.StatusCode)
30+
}
31+
}
32+
33+
// (limit+1)th should be rejected
34+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
35+
serviceAddr()+"/check", nil)
36+
req.Header.Set("X-API-Key", apiKey)
37+
resp, err := client.Do(req)
38+
if err != nil {
39+
t.Fatalf("over-limit request failed: %v", err)
40+
}
41+
defer resp.Body.Close()
42+
43+
if resp.StatusCode != http.StatusTooManyRequests {
44+
t.Errorf("over-limit request: expected 429, got %d", resp.StatusCode)
45+
}
46+
}
47+
48+
func TestBurstExhaustion(t *testing.T) {
49+
const limit = 100
50+
const burst = 200
51+
client := &http.Client{Timeout: 2 * time.Second}
52+
apiKey := "test-burst-key"
53+
54+
allowed := 0
55+
for i := 0; i < burst; i++ {
56+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
57+
serviceAddr()+"/check", nil)
58+
req.Header.Set("X-API-Key", apiKey)
59+
60+
resp, err := client.Do(req)
61+
if err != nil {
62+
continue
63+
}
64+
resp.Body.Close()
65+
66+
if resp.StatusCode == http.StatusOK {
67+
allowed++
68+
}
69+
}
70+
71+
if allowed > limit {
72+
t.Errorf("burst admitted %d requests, limit is %d", allowed, limit)
73+
}
74+
t.Logf("burst result: %d/%d allowed (limit: %d)", allowed, burst, limit)
75+
}
76+
77+
func TestMissingAPIKey(t *testing.T) {
78+
client := &http.Client{Timeout: 5 * time.Second}
79+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
80+
serviceAddr()+"/check", nil)
81+
82+
resp, err := client.Do(req)
83+
if err != nil {
84+
t.Fatalf("request failed: %v", err)
85+
}
86+
defer resp.Body.Close()
87+
88+
if resp.StatusCode != http.StatusBadRequest {
89+
t.Errorf("expected 400 for missing key, got %d", resp.StatusCode)
90+
}
91+
}

tests/integration/failure_test.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,82 @@
1-
// tests/integration/failure_test.go
1+
//go:build integration
2+
3+
package integration_test
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"testing"
9+
"time"
10+
)
11+
12+
// TestFailOpen verifies the service allows requests when Redis is unavailable.
13+
// Fail-open behaviour is a deliberate design choice - documented in docs/tradeoffs.md.
14+
//
15+
// To run manually:
16+
// 1. Stop Redis: docker stop <redis-container>
17+
// 2. Run: go test -tags=integration -run TestFailOpen -v ./tests/integration/...
18+
// 3. Expect: 200 OK with X-Rate-Limit-Status: fail-open
19+
// 4. Restart Redis and verify normal operation resumes
20+
func TestFailOpen(t *testing.T) {
21+
t.Skip("Run manually: stop Redis first, then unskip and run")
22+
23+
client := &http.Client{Timeout: 5 * time.Second}
24+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
25+
serviceAddr()+"/check", nil)
26+
req.Header.Set("X-API-Key", "fail-open-test")
27+
28+
resp, err := client.Do(req)
29+
if err != nil {
30+
t.Fatalf("Request failed: %v", err)
31+
}
32+
defer resp.Body.Close()
33+
34+
if resp.StatusCode != http.StatusOK {
35+
t.Errorf("Fail-open: expected 200, got %d", resp.StatusCode)
36+
}
37+
}
38+
39+
// TestHealthAfterRedisRestart verifies the service recovers and resumes
40+
// correct rate-limit decisions after Redis restarts.
41+
//
42+
// To run manually:
43+
// 1. Restart Redis: docker restart <redis-container>
44+
// 2. Run this test immediately after
45+
func TestHealthAfterRedisRestart(t *testing.T) {
46+
t.Skip("Run manually after restarting Redis")
47+
48+
client := &http.Client{Timeout: 10 * time.Second}
49+
50+
// Poll until the service is healthy again (up to 30s)
51+
deadline := time.Now().Add(30 * time.Second)
52+
for time.Now().Before(deadline) {
53+
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost,
54+
serviceAddr()+"/check", nil)
55+
req.Header.Set("X-API-Key", "recovery-test")
56+
57+
resp, err := client.Do(req)
58+
if err == nil && resp.StatusCode == http.StatusOK {
59+
resp.Body.Close()
60+
t.Log("Service recovered successfully")
61+
return
62+
}
63+
if resp != nil {
64+
resp.Body.Close()
65+
}
66+
time.Sleep(500 * time.Millisecond)
67+
}
68+
69+
t.Error("Service did not recover within 30s after Redis restart")
70+
}
71+
72+
// TestRollingDeployment documents the manual procedure for verifying
73+
// zero dropped requests during a Kubernetes rolling update.
74+
//
75+
// Procedure:
76+
// 1. Start a background load test: k6 run tests/load/k6_ramp.js
77+
// 2. In a second terminal: kubectl rollout restart deployment/rate-limiter
78+
// 3. Observe k6 output — http_req_failed rate should remain < 0.1%
79+
// 4. Verify p99 latency spike stays under 50ms during rollover
80+
func TestRollingDeployment(t *testing.T) {
81+
t.Skip("Manual test - see procedure in comment above")
82+
}
Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,77 @@
1-
// tests/integration/multi_replica_test.go
1+
package integration_test
2+
3+
import (
4+
"context"
5+
"sync"
6+
"sync/atomic"
7+
"testing"
8+
"time"
9+
10+
"github.com/Pavan-Rana/rate-limiter/internal/limiter"
11+
"github.com/Pavan-Rana/rate-limiter/internal/store"
12+
)
13+
14+
type testConfig struct {
15+
policy limiter.Policy
16+
}
17+
18+
func (c *testConfig) PolicyFor(apiKey string) (limiter.Policy, bool) {
19+
return c.policy, true
20+
}
21+
22+
func (c *testConfig) GetDefault() limiter.Policy {
23+
return c.policy
24+
}
25+
26+
func TestMultiReplica_GlobalLimitEnforced(t *testing.T) {
27+
ctx := context.Background()
28+
redisAddr := "localhost:6379"
29+
apiKey := "test-key"
30+
31+
policy := limiter.Policy{
32+
Limit: 50,
33+
Window: time.Second,
34+
}
35+
36+
cfg := &testConfig{policy: policy}
37+
const replicas = 4
38+
const totalRequests = 200
39+
limiters := make([]*limiter.Limiter, replicas)
40+
for i := 0; i < replicas; i++ {
41+
store, err := store.NewRedisStore(redisAddr)
42+
if err != nil {
43+
t.Fatalf("failed to create redis store: %v", err)
44+
}
45+
limiters[i] = limiter.New(store, cfg)
46+
}
47+
48+
var allowed int64
49+
var wg sync.WaitGroup
50+
start := make(chan struct{})
51+
52+
for i := 0; i < totalRequests; i++ {
53+
wg.Add(1)
54+
55+
go func(i int) {
56+
defer wg.Done()
57+
<-start
58+
l := limiters[i%replicas]
59+
60+
ok, err := l.AllowRequest(ctx, apiKey)
61+
if err != nil {
62+
t.Logf("store error: %v", err)
63+
}
64+
if ok {
65+
atomic.AddInt64(&allowed, 1)
66+
}
67+
}(i)
68+
}
69+
70+
close(start)
71+
wg.Wait()
72+
73+
if allowed > int64(policy.Limit) {
74+
t.Fatalf("global limit violated: allowed=%d > limit=%d", allowed, policy.Limit)
75+
}
76+
t.Logf("allowed=%d (limit=%d)", allowed, policy.Limit)
77+
}

0 commit comments

Comments
 (0)