mirror of
https://github.com/hanzoai/insights-go.git
synced 2026-09-30 22:46:12 +00:00
396 lines
10 KiB
Go
396 lines
10 KiB
Go
package insights
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newTestPoller creates a FeatureFlagsPoller for testing without starting the background goroutine.
|
|
// This allows tests to call fetchNewFeatureFlags() directly for synchronous, deterministic testing.
|
|
func newTestPoller(t *testing.T, serverURL string) *FeatureFlagsPoller {
|
|
t.Helper()
|
|
|
|
localEvalURL, err := url.Parse(serverURL + "/flags/definitions")
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse URL: %v", err)
|
|
}
|
|
|
|
return &FeatureFlagsPoller{
|
|
personalApiKey: "test-personal-key",
|
|
projectApiKey: "test-api-key",
|
|
localEvalUrl: localEvalURL,
|
|
Logger: newDefaultLogger(false),
|
|
Endpoint: serverURL,
|
|
http: http.Client{},
|
|
flagTimeout: 10 * time.Second,
|
|
}
|
|
}
|
|
|
|
func TestETagSupportForLocalEvaluation(t *testing.T) {
|
|
t.Run("stores ETag from initial response", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
w.Header().Set("ETag", `"abc123"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "test-flag", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state := poller.state.Load()
|
|
etag := ""
|
|
if state != nil {
|
|
etag = state.flagsEtag
|
|
}
|
|
|
|
if etag != `"abc123"` {
|
|
t.Errorf("Expected ETag to be \"abc123\", got %q", etag)
|
|
}
|
|
})
|
|
|
|
t.Run("sends If-None-Match header on subsequent requests", func(t *testing.T) {
|
|
var requestCount int
|
|
var receivedIfNoneMatch string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
requestCount++
|
|
receivedIfNoneMatch = r.Header.Get("If-None-Match")
|
|
|
|
if requestCount == 1 {
|
|
w.Header().Set("ETag", `"initial-etag"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "test-flag", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
} else {
|
|
w.Header().Set("ETag", `"initial-etag"`)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
|
|
// First request - no If-None-Match
|
|
poller.fetchNewFeatureFlags()
|
|
if requestCount != 1 {
|
|
t.Fatalf("Expected 1 request, got %d", requestCount)
|
|
}
|
|
|
|
// Second request - should include If-None-Match
|
|
poller.fetchNewFeatureFlags()
|
|
if requestCount != 2 {
|
|
t.Fatalf("Expected 2 requests, got %d", requestCount)
|
|
}
|
|
|
|
if receivedIfNoneMatch != `"initial-etag"` {
|
|
t.Errorf("Expected If-None-Match header to be \"initial-etag\", got %q", receivedIfNoneMatch)
|
|
}
|
|
})
|
|
|
|
t.Run("handles 304 Not Modified response", func(t *testing.T) {
|
|
var requestCount int
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
requestCount++
|
|
|
|
if requestCount == 1 {
|
|
w.Header().Set("ETag", `"test-etag"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "cached-flag", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {"0": "company"},
|
|
"cohorts": {}
|
|
}`))
|
|
} else {
|
|
w.Header().Set("ETag", `"test-etag"`)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
|
|
// First request - get flags
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state := poller.state.Load()
|
|
if state == nil || len(state.featureFlags) != 1 || state.featureFlags[0].Key != "cached-flag" {
|
|
t.Errorf("Expected initial flag 'cached-flag', got %+v", state)
|
|
}
|
|
|
|
// Second request - 304
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
// Flags should still be available after 304
|
|
state = poller.state.Load()
|
|
if state == nil || len(state.featureFlags) != 1 || state.featureFlags[0].Key != "cached-flag" {
|
|
t.Errorf("Expected flags to remain 'cached-flag' after 304, got %+v", state)
|
|
}
|
|
})
|
|
|
|
t.Run("updates ETag when flags change", func(t *testing.T) {
|
|
var requestCount int
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
requestCount++
|
|
|
|
if requestCount == 1 {
|
|
w.Header().Set("ETag", `"etag-v1"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "flag-v1", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
} else {
|
|
w.Header().Set("ETag", `"etag-v2"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "flag-v2", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state := poller.state.Load()
|
|
etag1 := ""
|
|
if state != nil {
|
|
etag1 = state.flagsEtag
|
|
}
|
|
|
|
if etag1 != `"etag-v1"` {
|
|
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
|
}
|
|
|
|
// Second request - new ETag
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state = poller.state.Load()
|
|
etag2 := ""
|
|
var flags []FeatureFlag
|
|
if state != nil {
|
|
etag2 = state.flagsEtag
|
|
flags = state.featureFlags
|
|
}
|
|
|
|
if etag2 != `"etag-v2"` {
|
|
t.Errorf("Expected updated ETag to be \"etag-v2\", got %q", etag2)
|
|
}
|
|
if len(flags) != 1 || flags[0].Key != "flag-v2" {
|
|
t.Errorf("Expected flag to be updated to 'flag-v2', got %+v", flags)
|
|
}
|
|
})
|
|
|
|
t.Run("clears ETag when server stops sending it", func(t *testing.T) {
|
|
var requestCount int
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
requestCount++
|
|
|
|
if requestCount == 1 {
|
|
w.Header().Set("ETag", `"etag-v1"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "flag-v1", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
} else {
|
|
// No ETag in response
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "flag-v2", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state := poller.state.Load()
|
|
etag1 := ""
|
|
if state != nil {
|
|
etag1 = state.flagsEtag
|
|
}
|
|
|
|
if etag1 != `"etag-v1"` {
|
|
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
|
}
|
|
|
|
// Second request - no ETag
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state = poller.state.Load()
|
|
etag2 := ""
|
|
if state != nil {
|
|
etag2 = state.flagsEtag
|
|
}
|
|
|
|
if etag2 != "" {
|
|
t.Errorf("Expected ETag to be cleared when server stops sending it, got %q", etag2)
|
|
}
|
|
})
|
|
|
|
t.Run("preserves ETag when 304 response has no ETag header", func(t *testing.T) {
|
|
var requestCount int
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
requestCount++
|
|
|
|
if requestCount == 1 {
|
|
w.Header().Set("ETag", `"etag-v1"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "flag-v1", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
} else {
|
|
// 304 without ETag header (unusual but possible)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state := poller.state.Load()
|
|
etag1 := ""
|
|
if state != nil {
|
|
etag1 = state.flagsEtag
|
|
}
|
|
|
|
if etag1 != `"etag-v1"` {
|
|
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
|
}
|
|
|
|
// Second request - 304 without ETag
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state = poller.state.Load()
|
|
etag2 := ""
|
|
var flags []FeatureFlag
|
|
if state != nil {
|
|
etag2 = state.flagsEtag
|
|
flags = state.featureFlags
|
|
}
|
|
|
|
if etag2 != `"etag-v1"` {
|
|
t.Errorf("Expected ETag to be preserved when 304 has no ETag header, got %q", etag2)
|
|
}
|
|
if len(flags) != 1 || flags[0].Key != "flag-v1" {
|
|
t.Errorf("Expected flags to remain after 304 without ETag, got %+v", flags)
|
|
}
|
|
})
|
|
|
|
t.Run("clears ETag on quota limit (402)", func(t *testing.T) {
|
|
var requestCount int
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
requestCount++
|
|
|
|
if requestCount == 1 {
|
|
w.Header().Set("ETag", `"etag-v1"`)
|
|
w.Write([]byte(`{
|
|
"flags": [{"key": "flag-v1", "active": true, "filters": {"groups": []}}],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
} else {
|
|
w.WriteHeader(http.StatusPaymentRequired)
|
|
w.Write([]byte(`{
|
|
"type": "quota_limited",
|
|
"detail": "You have exceeded your feature flag request quota"
|
|
}`))
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state := poller.state.Load()
|
|
etag1 := ""
|
|
if state != nil {
|
|
etag1 = state.flagsEtag
|
|
}
|
|
|
|
if etag1 != `"etag-v1"` {
|
|
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
|
}
|
|
|
|
// Second request - quota limited
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
state = poller.state.Load()
|
|
etag2 := ""
|
|
var flags []FeatureFlag
|
|
if state != nil {
|
|
etag2 = state.flagsEtag
|
|
flags = state.featureFlags
|
|
}
|
|
|
|
if etag2 != "" {
|
|
t.Errorf("Expected ETag to be cleared on quota limit, got %q", etag2)
|
|
}
|
|
if len(flags) != 0 {
|
|
t.Errorf("Expected flags to be cleared on quota limit, got %+v", flags)
|
|
}
|
|
})
|
|
|
|
t.Run("first request does not send If-None-Match header", func(t *testing.T) {
|
|
var firstRequestIfNoneMatch string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
|
|
firstRequestIfNoneMatch = r.Header.Get("If-None-Match")
|
|
w.Header().Set("ETag", `"abc123"`)
|
|
w.Write([]byte(`{
|
|
"flags": [],
|
|
"group_type_mapping": {},
|
|
"cohorts": {}
|
|
}`))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
poller := newTestPoller(t, server.URL)
|
|
poller.fetchNewFeatureFlags()
|
|
|
|
if firstRequestIfNoneMatch != "" {
|
|
t.Errorf("Expected no If-None-Match header on first request, got %q", firstRequestIfNoneMatch)
|
|
}
|
|
})
|
|
}
|