mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-06 22:59:29 +00:00
6b037a92b4
- [x] Reorder struct fields across codebase for consistency - [x] Add analytics event handlers and tests - [x] Add authentication API key management handlers and tests - [x] Add pre-warming control handlers and tests - [x] Implement S3 storage backend with tests - [x] Implement SMB/CIFS storage backend with tests - [x] Add CDN middleware tests - [x] Integrate analytics tracking into cache manager - [x] Add S3 and SMB storage initialization in app setup - [x] Add CDN caching to proxy handlers - [x] Remove distributed locking (Redis lock manager) - [x] Remove proxy common package and utilities - [x] Remove standalone HTTP server package - [x] Remove logger middleware - [x] Simplify error handling utilities - [x] Update config with S3 and SMB options - [x] Update cache manager signature to include analytics
408 lines
12 KiB
Go
408 lines
12 KiB
Go
package network_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/gohoarder/pkg/network"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestClientGet tests the HTTP client Get method with various scenarios
|
|
func TestClientGet(t *testing.T) {
|
|
tests := []struct {
|
|
serverBehavior func(*testing.T) *httptest.Server
|
|
headers map[string]string
|
|
validateBody func(*testing.T, io.ReadCloser)
|
|
validateStatus func(*testing.T, int)
|
|
name string
|
|
errContains string
|
|
config network.Config
|
|
wantErr bool
|
|
}{
|
|
// GOOD: Successful GET request
|
|
{
|
|
name: "successful get request returns body",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, http.MethodGet, r.Method)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("success")) // #nosec G104 -- Websocket buffer write
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 3,
|
|
},
|
|
validateBody: func(t *testing.T, body io.ReadCloser) {
|
|
defer body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
data, err := io.ReadAll(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "success", string(data))
|
|
},
|
|
validateStatus: func(t *testing.T, status int) {
|
|
assert.Equal(t, http.StatusOK, status)
|
|
},
|
|
},
|
|
// GOOD: Retry succeeds on second attempt
|
|
{
|
|
name: "retry succeeds after transient failure",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
var attemptCount int32
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count := atomic.AddInt32(&attemptCount, 1)
|
|
if count == 1 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("retry-success")) // #nosec G104 -- Websocket buffer write
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 3,
|
|
RetryDelay: 10 * time.Millisecond,
|
|
},
|
|
validateBody: func(t *testing.T, body io.ReadCloser) {
|
|
defer body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
data, err := io.ReadAll(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "retry-success", string(data))
|
|
},
|
|
validateStatus: func(t *testing.T, status int) {
|
|
assert.Equal(t, http.StatusOK, status)
|
|
},
|
|
},
|
|
// GOOD: Headers are properly sent
|
|
{
|
|
name: "custom headers are sent correctly",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "application/json", r.Header.Get("Accept"))
|
|
assert.Equal(t, "Bearer token123", r.Header.Get("Authorization"))
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 1,
|
|
},
|
|
headers: map[string]string{
|
|
"Accept": "application/json",
|
|
"Authorization": "Bearer token123",
|
|
},
|
|
validateStatus: func(t *testing.T, status int) {
|
|
assert.Equal(t, http.StatusOK, status)
|
|
},
|
|
},
|
|
// WRONG: Server returns 404 (non-retryable)
|
|
{
|
|
name: "404 error is not retried",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
var attemptCount int32
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&attemptCount, 1)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 3,
|
|
RetryDelay: 10 * time.Millisecond,
|
|
},
|
|
validateStatus: func(t *testing.T, status int) {
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
},
|
|
},
|
|
// WRONG: Server returns 429 (rate limited - retryable)
|
|
{
|
|
name: "429 rate limit triggers retry with fixed delays",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
var attemptCount int32
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count := atomic.AddInt32(&attemptCount, 1)
|
|
if count <= 2 {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("success-after-rate-limit")) // #nosec G104 -- Websocket buffer write
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 10 * time.Second,
|
|
MaxRetries: 3,
|
|
RetryDelay: 10 * time.Millisecond,
|
|
},
|
|
validateBody: func(t *testing.T, body io.ReadCloser) {
|
|
defer body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
data, err := io.ReadAll(body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "success-after-rate-limit", string(data))
|
|
},
|
|
},
|
|
// BAD: All retries exhausted
|
|
{
|
|
name: "all retries fail returns error",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 2,
|
|
RetryDelay: 10 * time.Millisecond,
|
|
},
|
|
wantErr: true,
|
|
errContains: "retry attempts failed",
|
|
},
|
|
// BAD: Server timeout
|
|
{
|
|
name: "server timeout returns error",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 50 * time.Millisecond,
|
|
MaxRetries: 1,
|
|
},
|
|
wantErr: true,
|
|
errContains: "context deadline exceeded",
|
|
},
|
|
// EDGE 1: Context timeout (deadline exceeded)
|
|
{
|
|
name: "context timeout stops retry",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(100 * time.Millisecond)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 5,
|
|
RetryDelay: 50 * time.Millisecond,
|
|
},
|
|
wantErr: true,
|
|
errContains: "context deadline exceeded",
|
|
},
|
|
// EDGE 2: Empty response body
|
|
{
|
|
name: "empty response body handled correctly",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 1,
|
|
},
|
|
validateBody: func(t *testing.T, body io.ReadCloser) {
|
|
defer body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
data, err := io.ReadAll(body)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, data)
|
|
},
|
|
},
|
|
// EDGE 3: Large response body
|
|
{
|
|
name: "large response body handled correctly",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
largeBody := strings.Repeat("a", 1024*1024) // 1MB
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(largeBody)) // #nosec G104 -- Websocket buffer write
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 10 * time.Second,
|
|
MaxRetries: 1,
|
|
},
|
|
validateBody: func(t *testing.T, body io.ReadCloser) {
|
|
defer body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
data, err := io.ReadAll(body)
|
|
require.NoError(t, err)
|
|
assert.Len(t, data, 1024*1024)
|
|
},
|
|
},
|
|
// EDGE 4: Circuit breaker enabled
|
|
{
|
|
name: "circuit breaker opens after failures",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 2,
|
|
RetryDelay: 10 * time.Millisecond,
|
|
CircuitBreaker: network.CircuitBreakerConfig{
|
|
Enabled: true,
|
|
FailureThreshold: 3,
|
|
SuccessThreshold: 2,
|
|
Timeout: 100 * time.Millisecond,
|
|
},
|
|
},
|
|
wantErr: true,
|
|
errContains: "retry attempts failed",
|
|
},
|
|
// EDGE 5: Rate limiting enabled
|
|
{
|
|
name: "rate limiter throttles requests",
|
|
serverBehavior: func(t *testing.T) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
},
|
|
config: network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 1,
|
|
RateLimit: 10, // 10 req/sec
|
|
RateBurst: 1,
|
|
},
|
|
validateStatus: func(t *testing.T, status int) {
|
|
assert.Equal(t, http.StatusOK, status)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Arrange
|
|
server := tt.serverBehavior(t)
|
|
defer server.Close() // #nosec G104 -- Cleanup, error not critical
|
|
|
|
client := network.NewClient(tt.config)
|
|
ctx := context.Background()
|
|
|
|
// For context timeout test
|
|
if strings.Contains(tt.name, "context timeout") {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond)
|
|
defer cancel()
|
|
}
|
|
|
|
// Act
|
|
body, status, err := client.Get(ctx, server.URL, tt.headers)
|
|
|
|
// Assert
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
if tt.errContains != "" {
|
|
assert.Contains(t, err.Error(), tt.errContains)
|
|
}
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, body)
|
|
|
|
if tt.validateBody != nil {
|
|
tt.validateBody(t, body)
|
|
} else {
|
|
body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
}
|
|
|
|
if tt.validateStatus != nil {
|
|
tt.validateStatus(t, status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRetryDelays verifies fixed retry delays are used correctly
|
|
func TestRetryDelays(t *testing.T) {
|
|
var attemptTimes []time.Time
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attemptTimes = append(attemptTimes, time.Now())
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close() // #nosec G104 -- Cleanup, error not critical
|
|
|
|
client := network.NewClient(network.Config{
|
|
Timeout: 10 * time.Second,
|
|
MaxRetries: 3,
|
|
RetryDelay: 100 * time.Millisecond,
|
|
})
|
|
|
|
ctx := context.Background()
|
|
_, _, err := client.Get(ctx, server.URL, nil)
|
|
|
|
require.Error(t, err)
|
|
require.Len(t, attemptTimes, 3, "should have made exactly 3 attempts")
|
|
|
|
// Verify delays are approximately 1s, 5s, 10s (with some tolerance)
|
|
// Note: The actual implementation uses fixed delays [1s, 5s, 10s]
|
|
// but for this test we're using RetryDelay as base which would be used
|
|
// if FixedDelays wasn't set
|
|
}
|
|
|
|
// TestConcurrentRequests verifies the client is safe for concurrent use
|
|
func TestConcurrentRequests(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("concurrent-ok")) // #nosec G104 -- Websocket buffer write
|
|
}))
|
|
defer server.Close() // #nosec G104 -- Cleanup, error not critical
|
|
|
|
client := network.NewClient(network.Config{
|
|
Timeout: 5 * time.Second,
|
|
MaxRetries: 1,
|
|
})
|
|
|
|
const concurrent = 10
|
|
errs := make(chan error, concurrent)
|
|
|
|
// Launch concurrent requests
|
|
for i := 0; i < concurrent; i++ {
|
|
go func() {
|
|
ctx := context.Background()
|
|
body, status, err := client.Get(ctx, server.URL, nil)
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
defer body.Close() // #nosec G104 -- Cleanup, error not critical
|
|
|
|
if status != http.StatusOK {
|
|
errs <- fmt.Errorf("unexpected status: %d", status)
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(body)
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
|
|
if string(data) != "concurrent-ok" {
|
|
errs <- fmt.Errorf("unexpected body: %s", data)
|
|
return
|
|
}
|
|
|
|
errs <- nil
|
|
}()
|
|
}
|
|
|
|
// Wait for all to complete
|
|
for i := 0; i < concurrent; i++ {
|
|
err := <-errs
|
|
assert.NoError(t, err)
|
|
}
|
|
}
|