All checks were successful
continuous-integration/drone/push Build is passing
- Complete unit, integration, and E2E test coverage (189 test cases) - Enhanced CI/CD pipeline with race detection and quality checks - Comprehensive godoc documentation for all packages - Updated README with API docs, examples, and deployment guides
408 lines
11 KiB
Go
408 lines
11 KiB
Go
package geoipapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/oschwald/geoip2-golang"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestGetCity(t *testing.T) {
|
|
// Save original environment variable
|
|
originalGeoIPService := os.Getenv("geoip_service")
|
|
defer func() {
|
|
if originalGeoIPService == "" {
|
|
os.Unsetenv("geoip_service")
|
|
} else {
|
|
os.Setenv("geoip_service", originalGeoIPService)
|
|
}
|
|
}()
|
|
|
|
t.Run("Missing geoip_service environment variable", func(t *testing.T) {
|
|
os.Unsetenv("geoip_service")
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "geoip_service not configured")
|
|
})
|
|
|
|
t.Run("Successful API call", func(t *testing.T) {
|
|
// Create mock server
|
|
mockCity := &geoip2.City{}
|
|
mockCity.Country.GeoNameID = 6252001
|
|
mockCity.Country.IsoCode = "US"
|
|
mockCity.Country.Names = map[string]string{"en": "United States"}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify the request
|
|
assert.Equal(t, "GET", r.Method)
|
|
assert.Equal(t, "/api/json", r.URL.Path)
|
|
assert.Equal(t, "8.8.8.8", r.URL.Query().Get("ip"))
|
|
|
|
// Return mock response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(mockCity)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Set environment variable to point to mock server
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
city, err := GetCity(ctx, ip)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, city)
|
|
assert.Equal(t, "US", city.Country.IsoCode)
|
|
assert.Equal(t, "United States", city.Country.Names["en"])
|
|
})
|
|
|
|
t.Run("Server returns error", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("Internal server error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Invalid JSON response", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("invalid json"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Network timeout", func(t *testing.T) {
|
|
// Create server that delays response beyond client timeout
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(15 * time.Second) // Longer than client timeout
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
// Should be a timeout or context deadline exceeded error
|
|
})
|
|
|
|
t.Run("Context cancellation", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(1 * time.Second)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "context canceled")
|
|
})
|
|
|
|
t.Run("Different IP address formats", func(t *testing.T) {
|
|
testIPs := []struct {
|
|
ip string
|
|
expected string
|
|
}{
|
|
{"192.168.1.1", "192.168.1.1"},
|
|
{"::1", "::1"},
|
|
{"2001:4860:4860::8888", "2001:4860:4860::8888"},
|
|
{"127.0.0.1", "127.0.0.1"},
|
|
}
|
|
|
|
for _, tt := range testIPs {
|
|
t.Run("IP_"+tt.ip, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify the IP parameter
|
|
assert.Equal(t, tt.expected, r.URL.Query().Get("ip"))
|
|
|
|
mockCity := &geoip2.City{}
|
|
mockCity.Country.IsoCode = "XX"
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(mockCity)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr(tt.ip)
|
|
|
|
city, err := GetCity(ctx, ip)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "XX", city.Country.IsoCode)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHTTPClientConfiguration(t *testing.T) {
|
|
t.Run("Client timeout configuration", func(t *testing.T) {
|
|
// Test that client has appropriate timeout
|
|
assert.Equal(t, 10*time.Second, client.Timeout)
|
|
})
|
|
|
|
t.Run("Transport configuration", func(t *testing.T) {
|
|
// Verify transport is configured
|
|
transport, ok := client.Transport.(*http.Transport)
|
|
assert.True(t, ok)
|
|
assert.NotNil(t, transport)
|
|
|
|
// Check timeout settings
|
|
assert.Equal(t, 5*time.Second, transport.TLSHandshakeTimeout)
|
|
})
|
|
}
|
|
|
|
func TestURLConstruction(t *testing.T) {
|
|
t.Run("URL construction with query parameters", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify URL construction
|
|
assert.Equal(t, "/api/json", r.URL.Path)
|
|
assert.Equal(t, "8.8.8.8", r.URL.Query().Get("ip"))
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
GetCity(ctx, ip)
|
|
})
|
|
|
|
t.Run("URL encoding of special characters", func(t *testing.T) {
|
|
// Test IPv6 addresses that might need URL encoding
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ipParam := r.URL.Query().Get("ip")
|
|
assert.Equal(t, "2001:4860:4860::8888", ipParam)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("{}"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("2001:4860:4860::8888")
|
|
|
|
GetCity(ctx, ip)
|
|
})
|
|
}
|
|
|
|
func TestErrorHandling(t *testing.T) {
|
|
t.Run("Network connection refused", func(t *testing.T) {
|
|
// Point to a non-existent server
|
|
os.Setenv("geoip_service", "localhost:99999")
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Invalid hostname", func(t *testing.T) {
|
|
// Point to invalid hostname
|
|
os.Setenv("geoip_service", "invalid-hostname-that-does-not-exist:8009")
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("Empty response body", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// No body
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestOpenTelemetryIntegration(t *testing.T) {
|
|
t.Run("Tracing span creation", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mockCity := &geoip2.City{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(mockCity)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
// Test that the function runs without panicking
|
|
// (actual tracing verification would require more complex setup)
|
|
city, err := GetCity(ctx, ip)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, city)
|
|
})
|
|
}
|
|
|
|
func TestConcurrentRequests(t *testing.T) {
|
|
t.Run("Concurrent API calls", func(t *testing.T) {
|
|
var requestCount int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count := atomic.AddInt64(&requestCount, 1)
|
|
|
|
mockCity := &geoip2.City{}
|
|
mockCity.Country.IsoCode = fmt.Sprintf("T%d", count)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(mockCity)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Launch multiple concurrent requests
|
|
numRequests := 5
|
|
results := make(chan error, numRequests)
|
|
|
|
for i := 0; i < numRequests; i++ {
|
|
go func() {
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
_, err := GetCity(ctx, ip)
|
|
results <- err
|
|
}()
|
|
}
|
|
|
|
// Collect results
|
|
for i := 0; i < numRequests; i++ {
|
|
err := <-results
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, int64(numRequests), atomic.LoadInt64(&requestCount))
|
|
})
|
|
}
|
|
|
|
func TestNetIPAddressHandling(t *testing.T) {
|
|
t.Run("netip.Addr conversions", func(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
ip string
|
|
}{
|
|
{"IPv4", "192.168.1.1"},
|
|
{"IPv6", "2001:db8::1"},
|
|
{"IPv4 mapped IPv6", "::ffff:192.168.1.1"},
|
|
{"Loopback IPv4", "127.0.0.1"},
|
|
{"Loopback IPv6", "::1"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
addr := netip.MustParseAddr(tc.ip)
|
|
|
|
// Test that netip.Addr can be converted to string properly
|
|
addrStr := addr.String()
|
|
assert.NotEmpty(t, addrStr)
|
|
|
|
// Test that we can parse it back
|
|
parsed := net.ParseIP(addrStr)
|
|
assert.NotNil(t, parsed)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHTTPHeaderHandling(t *testing.T) {
|
|
t.Run("HTTP headers in requests", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify that standard HTTP headers are present
|
|
assert.NotEmpty(t, r.Header.Get("User-Agent"))
|
|
|
|
mockCity := &geoip2.City{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(mockCity)
|
|
}))
|
|
defer server.Close()
|
|
|
|
serverAddr := server.Listener.Addr().String()
|
|
os.Setenv("geoip_service", serverAddr)
|
|
|
|
ctx := context.Background()
|
|
ip := netip.MustParseAddr("8.8.8.8")
|
|
|
|
_, err := GetCity(ctx, ip)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|