Files
insights-go/insights_test.go
zeekayandHanzo Dev 8ee8950230 Go 1.27.1, and the code it can say more simply
The module declares go 1.27.1 and the adapter image follows. go fix
applied the modernizers and left ptrString and ptrBool with no callers,
so they are gone; omitzero is not applied, since it changes what the
JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:52:31 -07:00

2796 lines
76 KiB
Go

package insights
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
json "github.com/goccy/go-json"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
)
// Helper type used to implement the io.Reader interface on function values.
type readFunc func([]byte) (int, error)
func (f readFunc) Read(b []byte) (int, error) { return f(b) }
// Helper type used to implement the http.RoundTripper interface on function
// values.
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func (f roundTripperFunc) CancelRequest(r *http.Request) {}
// Instances of this type are used to mock the client callbacks in unit tests.
type testCallback struct {
success func(APIMessage)
failure func(APIMessage, error)
}
func (c testCallback) Success(m APIMessage) {
if c.success != nil {
c.success(m)
}
}
func (c testCallback) Failure(m APIMessage, e error) {
if c.failure != nil {
c.failure(m, e)
}
}
// Instances of this type are used to mock the client logger in unit tests.
type testLogger struct {
logf func(string, ...any)
errorf func(string, ...any)
}
func (l testLogger) Debugf(format string, args ...any) {
if l.logf != nil {
l.logf(format, args...)
}
}
func (l testLogger) Logf(format string, args ...any) {
if l.logf != nil {
l.logf(format, args...)
}
}
func (l testLogger) Warnf(format string, args ...any) {
if l.logf != nil {
l.logf(format, args...)
}
}
func (l testLogger) Errorf(format string, args ...any) {
if l.errorf != nil {
l.errorf(format, args...)
}
}
func TestNewWithConfig_LogsErrorForBlankAPIKeyAfterTrim(t *testing.T) {
var logged string
client, err := NewWithConfig(" \n\t ", Config{
Logger: testLogger{
logf: t.Logf,
errorf: func(format string, args ...any) {
logged = fmt.Sprintf(format, args...)
},
},
})
require.NoError(t, err)
require.NotNil(t, client)
defer client.Close()
require.Contains(t, logged, "apiKey is empty after trimming whitespace")
require.Contains(t, logged, ErrSDKDisabled.Error())
}
func TestNew_BlankAPIKeyReturnsNoopClient(t *testing.T) {
client := New(" \n\t ")
require.NotNil(t, client)
_, ok := client.(*noopClient)
require.True(t, ok)
require.ErrorIs(t, client.Enqueue(Capture{}), ErrSDKDisabled)
isEnabled, err := client.IsFeatureEnabled(FeatureFlagPayload{})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Equal(t, false, isEnabled)
flag, err := client.GetFeatureFlag(FeatureFlagPayload{})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Equal(t, false, flag)
result, err := client.GetFeatureFlagResult(FeatureFlagPayload{})
require.ErrorIs(t, err, ErrSDKDisabled)
require.NotNil(t, result)
require.False(t, result.Enabled)
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Empty(t, payload)
allFlags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Empty(t, allFlags)
evaluations, err := client.EvaluateFlags(EvaluateFlagsPayload{})
require.ErrorIs(t, err, ErrSDKDisabled)
require.NotNil(t, evaluations)
require.Empty(t, evaluations.Keys())
require.ErrorIs(t, client.ReloadFeatureFlags(), ErrSDKDisabled)
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
}
func TestNewWithConfig_BlankAPIKeyReturnsNoopClientWithoutRequests(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
var successes atomic.Int64
var failures atomic.Int64
client, err := NewWithConfig(" \n\t ", Config{
Endpoint: server.URL,
PersonalApiKey: "personal-api-key",
BatchSize: 1,
Interval: time.Millisecond,
Callback: testCallback{
success: func(APIMessage) { successes.Add(1) },
failure: func(APIMessage, error) { failures.Add(1) },
},
})
require.NoError(t, err)
require.NotNil(t, client)
_, ok := client.(*noopClient)
require.True(t, ok)
require.ErrorIs(t, client.Enqueue(Capture{DistinctId: "test-user", Event: "test-event"}), ErrSDKDisabled)
isEnabled, err := client.IsFeatureEnabled(FeatureFlagPayload{Key: "test-flag", DistinctId: "test-user"})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Equal(t, false, isEnabled)
allFlags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{DistinctId: "test-user"})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Empty(t, allFlags)
remoteConfigPayload, err := client.GetRemoteConfigPayload("test-flag")
require.ErrorIs(t, err, ErrSDKDisabled)
require.Empty(t, remoteConfigPayload)
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
require.Zero(t, requests.Load())
require.Zero(t, successes.Load())
require.Zero(t, failures.Load())
}
func TestNewWithConfig_BlankAPIKeyWithPersonalAPIKeyReturnsNoopClient(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client, err := NewWithConfig(" \n\t ", Config{
Endpoint: server.URL,
PersonalApiKey: "personal-api-key",
BatchSize: 1,
Interval: time.Millisecond,
})
require.NoError(t, err)
require.NotNil(t, client)
_, ok := client.(*noopClient)
require.True(t, ok)
require.ErrorIs(t, client.Enqueue(Capture{DistinctId: "test-user", Event: "test-event"}), ErrSDKDisabled)
isEnabled, err := client.IsFeatureEnabled(FeatureFlagPayload{Key: "test-flag", DistinctId: "test-user"})
require.ErrorIs(t, err, ErrSDKDisabled)
require.Equal(t, false, isEnabled)
remoteConfigPayload, err := client.GetRemoteConfigPayload("test-flag")
require.ErrorIs(t, err, ErrSDKDisabled)
require.Empty(t, remoteConfigPayload)
require.ErrorIs(t, client.Close(), ErrSDKDisabled)
require.Zero(t, requests.Load())
}
func TestNewWithConfig_TrimsWhitespaceSensitiveInputsInRequests(t *testing.T) {
var (
mu sync.Mutex
batchAPIKey string
remoteConfigToken string
remoteConfigAuth string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/flags/definitions"):
if got := r.Header.Get("Authorization"); got != "Bearer test-personal-key" {
t.Errorf("Expected Authorization header 'Bearer test-personal-key', got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"flags": [], "group_type_mapping": {}}`))
case r.URL.Path == "/batch/":
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Failed to read batch body: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
var payload struct {
ApiKey string `json:"api_key"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("Failed to parse batch body: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
mu.Lock()
batchAPIKey = payload.ApiKey
mu.Unlock()
w.WriteHeader(http.StatusOK)
case strings.Contains(r.URL.Path, "/remote_config"):
mu.Lock()
remoteConfigToken = r.URL.Query().Get("token")
remoteConfigAuth = r.Header.Get("Authorization")
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`"ok"`))
default:
t.Errorf("Unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client, err := NewWithConfig(" \n test-api-key\t ", Config{
PersonalApiKey: " \ttest-personal-key\n ",
Endpoint: " \n" + server.URL + "\t ",
BatchSize: 1,
})
require.NoError(t, err)
payload, err := client.GetRemoteConfigPayload("test-flag")
require.NoError(t, err)
require.Equal(t, "ok", payload)
require.NoError(t, client.Enqueue(Capture{DistinctId: "test-user", Event: "test-event"}))
require.NoError(t, client.Close())
mu.Lock()
defer mu.Unlock()
require.Equal(t, "test-api-key", batchAPIKey)
require.Equal(t, "test-api-key", remoteConfigToken)
require.Equal(t, "Bearer test-personal-key", remoteConfigAuth)
}
var _ Message = (*testErrorMessage)(nil)
// Instances of this type are used to force message validation errors in unit
// tests.
type testErrorMessage struct{}
type testAPIErrorMessage struct{}
func (m testErrorMessage) internal() {
}
func (m testErrorMessage) Validate() error { return testError }
func (m testErrorMessage) APIfy() APIMessage {
return testAPIErrorMessage{}
}
var (
// A control error returned by mock functions to emulate a failure.
//lint:ignore ST1012 variable name is fine :D
testError = errors.New("test error")
// HTTP transport that always succeeds.
testTransportOK = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
testTransportFeatureFlagsOK = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
var body string
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
body = fixture("feature_flag/test-simple-flag.json")
}
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
})
// HTTP transport that sleeps for a little while and eventually succeeds.
testTransportDelayed = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
time.Sleep(10 * time.Millisecond)
return testTransportOK.RoundTrip(r)
})
// HTTP transport that always returns a 400.
testTransportBadRequest = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusBadRequest),
StatusCode: http.StatusBadRequest,
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
})
// HTTP transport that always returns a 400 with an erroring body reader.
testTransportBodyError = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusBadRequest),
StatusCode: http.StatusBadRequest,
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
Body: io.NopCloser(readFunc(func(b []byte) (int, error) { return 0, testError })),
Request: r,
}, nil
})
// HTTP transport that always return an error.
testTransportError = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return nil, testError
})
)
func fixture(name string) string {
f, err := os.Open(filepath.Join("fixtures", name))
if err != nil {
panic(err)
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
panic(err)
}
return string(b)
}
func assertPayloadEqual(t *testing.T, expected, actual string) {
t.Helper()
sysCtx := getSystemContext().ToProperties()
var expectedJSON, actualJSON map[string]any
require.NoError(t, json.Unmarshal([]byte(expected), &expectedJSON))
require.NoError(t, json.Unmarshal([]byte(actual), &actualJSON))
batch, ok := actualJSON["batch"].([]any)
require.True(t, ok, "actual JSON missing 'batch' array")
for i, item := range batch {
event, ok := item.(map[string]any)
if !ok {
continue
}
props, ok := event["properties"].(map[string]any)
if !ok {
continue
}
for key := range sysCtx {
if _, exists := props[key]; !exists {
t.Errorf("batch[%d] missing %s in properties", i, key)
}
}
}
knownSysCtxKeys := map[string]struct{}{
"$os": {},
"$os_version": {},
"$os_distro": {},
"$go_version": {},
}
opt := cmpopts.IgnoreMapEntries(func(k string, v any) bool {
_, ok := knownSysCtxKeys[k]
return ok
})
if diff := cmp.Diff(expectedJSON, actualJSON, opt); diff != "" {
t.Errorf("payload mismatch (-expected +actual):\n%s", diff)
}
}
func mockTime() time.Time {
// time.Unix(0, 0) fails on Circle
return time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
}
func mockServer() (chan []byte, *httptest.Server) {
done := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := bytes.NewBuffer(nil)
io.Copy(buf, r.Body)
var v any
err := json.Unmarshal(buf.Bytes(), &v)
if err != nil {
panic(err)
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
panic(err)
}
done <- b
}))
return done, server
}
func ExampleCapture() {
body, server := mockServer()
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
BatchSize: 1,
now: mockTime,
})
defer client.Close()
client.Enqueue(Capture{
Uuid: "00000000-0000-0000-0000-000000000000",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
})
fmt.Printf("%s\n", <-body)
// Output:
// {
// "api_key": "Csyjlnlun3OzyNJAafdlv",
// "batch": [
// {
// "distinct_id": "123456",
// "event": "Download",
// "library": "insights-go",
// "library_version": "1.0.0",
// "properties": {
// "$geoip_disable": true,
// "$lib": "insights-go",
// "$lib_version": "1.0.0",
// "application": "Insights Go",
// "platform": "macos",
// "version": "1.0.0"
// },
// "send_feature_flags": false,
// "timestamp": "2009-11-10T23:00:00Z",
// "type": "capture",
// "uuid": "00000000-0000-0000-0000-000000000000"
// }
// ]
// }
}
func TestCaptureNoProperties(t *testing.T) {
defer func() {
// Ensure that the test doesn't panic.
if recover() != nil {
t.Error("shouldnt have panicked when merging properties into nil properties")
}
}()
_, server := mockServer()
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
BatchSize: 1,
now: mockTime,
DefaultEventProperties: NewProperties().Set("service", "api"),
})
defer client.Close()
client.Enqueue(Capture{
Event: "Download",
DistinctId: "123456",
SendFeatureFlags: SendFeatureFlags(false),
})
}
func TestEnqueue(t *testing.T) {
exception := Exception{
Uuid: "00000000-0000-0000-0000-000000000004",
DistinctId: "my-user-id",
Timestamp: time.Date(2025, 8, 11, 20, 43, 37, 0, time.UTC),
DisableGeoIP: true,
ExceptionList: []ExceptionItem{
{
Type: "Exception Title",
Value: "Exception Description",
Stacktrace: &ExceptionStacktrace{
Type: "raw",
Frames: []StackFrame{
{
Filename: "/Users/Developer/insights-go/examples/main.go",
LineNo: 56,
Function: "main.main",
InApp: true,
Synthetic: false,
Platform: "go",
},
},
},
},
},
}
f, tv := false, true
tests := map[string]struct {
ref string
msg Message
disableGeoIP *bool
}{
"alias": {
strings.TrimSpace(fixture("test-enqueue-alias.json")),
Alias{Uuid: "00000000-0000-0000-0000-000000000001", Alias: "A", DistinctId: "B"},
&tv,
},
"identify": {
strings.TrimSpace(fixture("test-enqueue-identify.json")),
Identify{
Uuid: "00000000-0000-0000-0000-000000000002",
DistinctId: "B",
Properties: Properties{"email": "[email protected]"},
},
&tv,
},
"identify-default-geoip": {
strings.TrimSpace(fixture("test-enqueue-identify.json")),
Identify{
Uuid: "00000000-0000-0000-0000-000000000002",
DistinctId: "B",
Properties: Properties{"email": "[email protected]"},
},
nil,
},
"groupIdentify": {
strings.TrimSpace(fixture("test-enqueue-group-identify.json")),
GroupIdentify{
Uuid: "00000000-0000-0000-0000-000000000003",
DistinctId: "$organization_id:5",
Type: "organization",
Key: "id:5",
Properties: Properties{},
},
&tv,
},
"capture": {
strings.TrimSpace(fixture("test-enqueue-capture.json")),
Capture{
Uuid: "00000000-0000-0000-0000-000000000010",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
},
&f,
},
"captureWithDisableGeoIP": {
strings.TrimSpace(fixture("test-enqueue-capture-with-disable-geoip.json")),
Capture{
Uuid: "00000000-0000-0000-0000-000000000011",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
},
&tv,
},
"exception": {
strings.TrimSpace(fixture("test-enqueue-exception.json")),
exception,
&tv,
},
"*alias": {
strings.TrimSpace(fixture("test-enqueue-alias.json")),
&Alias{Uuid: "00000000-0000-0000-0000-000000000001", Alias: "A", DistinctId: "B"},
&tv,
},
"*identify": {
strings.TrimSpace(fixture("test-enqueue-identify.json")),
&Identify{
Uuid: "00000000-0000-0000-0000-000000000002",
DistinctId: "B",
Properties: Properties{"email": "[email protected]"},
},
&tv,
},
"*groupIdentify": {
strings.TrimSpace(fixture("test-enqueue-group-identify.json")),
&GroupIdentify{
Uuid: "00000000-0000-0000-0000-000000000003",
DistinctId: "$organization_id:5",
Type: "organization",
Key: "id:5",
Properties: Properties{},
},
&tv,
},
"*capture": {
strings.TrimSpace(fixture("test-enqueue-capture-with-uuid.json")),
&Capture{
Uuid: "11111111-1111-1111-1111-111111111111",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
},
&tv,
},
"*exception": {
strings.TrimSpace(fixture("test-enqueue-exception.json")),
&exception,
&tv,
},
}
body, server := mockServer()
defer server.Close()
for name, test := range tests {
t.Run(name, func(t *testing.T) {
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Verbose: true,
Logger: toLogger(t),
BatchSize: 1,
now: mockTime,
DisableGeoIP: test.disableGeoIP,
})
defer client.Close()
if err := client.Enqueue(test.msg); err != nil {
t.Error(err)
return
}
assertPayloadEqual(t, test.ref, string(<-body))
})
}
}
// TestEnqueue_UUID_PreSupplied verifies that user-provided UUIDs are preserved
// on outgoing events for all message types.
func TestEnqueue_UUID_PreSupplied(t *testing.T) {
tests := []struct {
name string
msg Message
uuid string
}{
{
name: "Capture",
msg: Capture{Uuid: "user-provided-uuid-capture", Event: "test", DistinctId: "user1"},
uuid: "user-provided-uuid-capture",
},
{
name: "Alias",
msg: Alias{Uuid: "user-provided-uuid-alias", Alias: "new", DistinctId: "user1"},
uuid: "user-provided-uuid-alias",
},
{
name: "Identify",
msg: Identify{Uuid: "user-provided-uuid-identify", DistinctId: "user1"},
uuid: "user-provided-uuid-identify",
},
{
name: "GroupIdentify",
msg: GroupIdentify{Uuid: "user-provided-uuid-group", Type: "company", Key: "acme"},
uuid: "user-provided-uuid-group",
},
{
name: "Exception",
msg: Exception{
Uuid: "user-provided-uuid-exception",
DistinctId: "user1",
ExceptionList: []ExceptionItem{
{Type: "Error", Value: "test error"},
},
},
uuid: "user-provided-uuid-exception",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, server := mockServer()
defer server.Close()
cli, _ := NewWithConfig("test-key", Config{
Endpoint: server.URL,
BatchSize: 1,
now: mockTime,
})
defer cli.Close()
err := cli.Enqueue(tt.msg)
require.NoError(t, err)
// Read the body sent to the server
select {
case res := <-body:
require.Contains(t, string(res), tt.uuid,
"pre-supplied UUID should be preserved in request body")
case <-time.After(time.Second):
t.Fatal("timeout waiting for request")
}
})
}
}
// TestEnqueue_UUID_AutoGenerated verifies that UUIDs are auto-generated when
// not provided by the user for all message types.
func TestEnqueue_UUID_AutoGenerated(t *testing.T) {
// UUID v4 format regex: 8-4-4-4-12 hex characters
uuidPattern := regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
tests := []struct {
name string
msg Message
}{
{
name: "Capture",
msg: Capture{Event: "test", DistinctId: "user1"},
},
{
name: "Alias",
msg: Alias{Alias: "new", DistinctId: "user1"},
},
{
name: "Identify",
msg: Identify{DistinctId: "user1"},
},
{
name: "GroupIdentify",
msg: GroupIdentify{Type: "company", Key: "acme"},
},
{
name: "Exception",
msg: Exception{
DistinctId: "user1",
ExceptionList: []ExceptionItem{
{Type: "Error", Value: "test error"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, server := mockServer()
defer server.Close()
cli, _ := NewWithConfig("test-key", Config{
Endpoint: server.URL,
BatchSize: 1,
now: mockTime,
})
defer cli.Close()
err := cli.Enqueue(tt.msg)
require.NoError(t, err)
// Read the body sent to the server
select {
case res := <-body:
resStr := string(res)
// Verify a valid UUID was generated
matches := uuidPattern.FindAllString(resStr, -1)
require.NotEmpty(t, matches,
"auto-generated UUID should be present in request body")
// Verify it's not empty string
require.NotContains(t, resStr, `"uuid":""`,
"UUID should not be empty")
case <-time.After(time.Second):
t.Fatal("timeout waiting for request")
}
})
}
}
var _ Message = (*customMessage)(nil)
type customMessage struct {
}
type customAPIMessage struct {
}
func (c *customMessage) internal() {
}
func (c *customMessage) Validate() error {
return nil
}
func (c *customMessage) APIfy() APIMessage {
return customAPIMessage{}
}
func TestEnqueuingCustomTypeFails(t *testing.T) {
client := New("0123456789")
err := client.Enqueue(&customMessage{})
require.Error(t, err)
require.EqualError(t, err, "messages with custom types cannot be enqueued: *insights.customMessage",
"invalid/missing error when queuing unsupported message")
}
func TestCaptureWithInterval(t *testing.T) {
const interval = 100 * time.Millisecond
var ref = strings.TrimSpace(fixture("test-interval-capture.json"))
body, server := mockServer()
defer server.Close()
t0 := time.Now()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Interval: interval,
Verbose: true,
Logger: toLogger(t),
now: mockTime,
})
defer client.Close()
client.Enqueue(Capture{
Uuid: "00000000-0000-0000-0000-000000000012",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
})
// Will flush in 100 milliseconds
assertPayloadEqual(t, ref, string(<-body))
if t1 := time.Now(); t1.Sub(t0) < interval {
t.Error("the flushing interval is too short:", interval)
}
}
func TestCaptureWithTimestamp(t *testing.T) {
var ref = strings.TrimSpace(fixture("test-timestamp-capture.json"))
body, server := mockServer()
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Verbose: true,
Logger: toLogger(t),
BatchSize: 1,
now: mockTime,
})
defer client.Close()
client.Enqueue(Capture{
Uuid: "00000000-0000-0000-0000-000000000013",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
Timestamp: time.Date(2015, time.July, 10, 23, 0, 0, 0, time.UTC),
})
assertPayloadEqual(t, ref, string(<-body))
}
func TestCaptureWithDefaultProperties(t *testing.T) {
var ref = strings.TrimSpace(fixture("test-merge-capture.json"))
body, server := mockServer()
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Verbose: true,
DefaultEventProperties: NewProperties().Set("service", "api"),
Logger: toLogger(t),
BatchSize: 1,
now: mockTime,
})
defer client.Close()
client.Enqueue(Capture{
Uuid: "00000000-0000-0000-0000-000000000014",
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": "1.0.0",
},
SendFeatureFlags: SendFeatureFlags(false),
Timestamp: time.Date(2015, time.July, 10, 23, 0, 0, 0, time.UTC),
})
assertPayloadEqual(t, ref, string(<-body))
}
func TestCaptureMany(t *testing.T) {
var ref = strings.TrimSpace(fixture("test-many-capture.json"))
body, server := mockServer()
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Verbose: true,
Logger: toLogger(t),
BatchSize: 3,
now: mockTime,
})
defer client.Close()
// UUIDs for the first batch (3 events) - remaining 2 events will be in a second batch
uuids := []string{
"00000000-0000-0000-0000-000000000015",
"00000000-0000-0000-0000-000000000016",
"00000000-0000-0000-0000-000000000017",
"00000000-0000-0000-0000-000000000018",
"00000000-0000-0000-0000-000000000019",
}
for i := range 5 {
client.Enqueue(Capture{
Uuid: uuids[i],
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": i,
},
SendFeatureFlags: SendFeatureFlags(false),
})
}
assertPayloadEqual(t, ref, string(<-body))
}
func TestClientCloseTwice(t *testing.T) {
client := New("0123456789")
require.NoError(t, client.Close())
require.EqualError(t, client.Close(), ErrClosed.Error())
require.EqualError(t, client.Enqueue(Capture{DistinctId: "1", Event: "A"}), ErrClosed.Error())
}
func TestClientConfigError(t *testing.T) {
client, err := NewWithConfig("0123456789", Config{
Interval: -1 * time.Second,
})
require.Error(t, err, "no error returned when creating a client with an invalid config")
require.ErrorAs(t, err, &ConfigError{}, "invalid error type returned when creating a client with an invalid config")
require.Nil(t, client)
}
func TestClientWithPersonalApiKeyClosing(t *testing.T) {
client, err := NewWithConfig("123", Config{
PersonalApiKey: "123",
Transport: testTransportFeatureFlagsOK,
})
require.NoError(t, err)
require.NoError(t, client.Close())
}
func TestClientEnqueueError(t *testing.T) {
client := New("0123456789")
defer client.Close()
if err := client.Enqueue(testErrorMessage{}); err != testError {
t.Error("invlaid error returned when queueing an invalid message:", err)
}
}
func TestClientCallback(t *testing.T) {
reschan := make(chan bool, 1)
errchan := make(chan error, 1)
client, _ := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
func(m APIMessage) { reschan <- true },
func(m APIMessage, e error) { errchan <- e },
},
Transport: testTransportOK,
})
client.Enqueue(Capture{
DistinctId: "A",
Event: "B",
})
client.Close()
select {
case <-reschan:
case err := <-errchan:
t.Error("failure callback triggered:", err)
}
}
func TestClientMarshalMessageError(t *testing.T) {
errchan := make(chan error, 1)
client, _ := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
nil,
func(m APIMessage, e error) { errchan <- e },
},
Transport: testTransportOK,
})
// Functions cannot be serializable, this should break the JSON marshaling
// and trigger the failure callback.
client.Enqueue(Capture{
DistinctId: "A",
Event: "B",
Properties: Properties{"invalid": func() {}},
})
client.Close()
if err := <-errchan; err == nil {
t.Error("failure callback not triggered for unserializable message")
} else if _, ok := err.(*json.UnsupportedTypeError); !ok {
t.Errorf("invalid error type returned by unserializable message: %T", err)
}
}
func TestClientErrorWithMalformedEndpoint(t *testing.T) {
_, err := NewWithConfig("0123456789", Config{
Endpoint: "://localhost:80", // Malformed endpoint URL.
Logger: testLogger{t.Logf, t.Logf},
Transport: testTransportOK,
})
require.ErrorContains(t, err, "invalid endpoint")
}
func TestClientRoundTripperError(t *testing.T) {
t.Parallel()
errchan := make(chan error, 1)
client, _ := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
nil,
func(m APIMessage, e error) { errchan <- e },
},
Transport: testTransportError,
})
client.Enqueue(Capture{DistinctId: "A", Event: "B"})
client.Close()
if err := <-errchan; err == nil {
t.Error("failure callback not triggered for an invalid request")
} else if e, ok := err.(*url.Error); !ok {
t.Errorf("invalid error returned by round tripper: %T: %s", err, err)
} else if e.Err != testError {
t.Errorf("invalid error returned by round tripper: %T: %s", e.Err, e.Err)
}
}
func TestClientRetryError(t *testing.T) {
errchan := make(chan error, 1)
client, _ := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
nil,
func(m APIMessage, e error) { errchan <- e },
},
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
return nil, testError
}),
BatchSize: 1,
RetryAfter: func(i int) time.Duration { return time.Millisecond },
})
client.Enqueue(Capture{DistinctId: "A", Event: "B"})
// Each retry should happen ~1 millisecond, this should give enough time to
// the test to trigger the failure callback.
time.Sleep(50 * time.Millisecond)
if err := <-errchan; err == nil {
t.Error("failure callback not triggered for a retry falure")
} else if e, ok := err.(*url.Error); !ok {
t.Errorf("invalid error returned by round tripper: %T: %s", err, err)
} else if e.Err != testError {
t.Errorf("invalid error returned by round tripper: %T: %s", e.Err, e.Err)
}
client.Close()
}
func TestClientResponse400(t *testing.T) {
t.Parallel()
errchan := make(chan error, 1)
client, _ := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
nil,
func(m APIMessage, e error) { errchan <- e },
},
// This HTTP transport always return 400's.
Transport: testTransportBadRequest,
})
client.Enqueue(Capture{DistinctId: "A", Event: "B"})
client.Close()
if err := <-errchan; err == nil {
t.Error("failure callback not triggered for a 400 response")
}
}
func TestClientResponseBodyError(t *testing.T) {
t.Parallel()
errchan := make(chan error, 1)
client, _ := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
nil,
func(m APIMessage, e error) { errchan <- e },
},
// This HTTP transport always return 400's with an erroring body.
Transport: testTransportBodyError,
})
client.Enqueue(Capture{DistinctId: "A", Event: "B"})
client.Close()
if err := <-errchan; err == nil {
t.Error("failure callback not triggered for a 400 response")
} else if err != testError {
t.Errorf("invalid error returned by erroring response body: %T: %s", err, err)
}
}
func TestClientWorkerPool(t *testing.T) {
successCount := atomic.Int32{}
client, err := NewWithConfig("0123456789", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
func(m APIMessage) { successCount.Add(1) },
func(m APIMessage, e error) { t.Errorf("unexpected failure: %v", e) },
},
Transport: testTransportDelayed,
// Use single worker with small batch size to test sequential processing
BatchSize: 1,
MaxEnqueuedRequests: 1,
})
require.NoError(t, err)
// Both messages should succeed with the worker pool (they queue up)
require.NoError(t, client.Enqueue(Capture{DistinctId: "A", Event: "B"}))
require.NoError(t, client.Enqueue(Capture{DistinctId: "A", Event: "B"}))
require.NoError(t, client.Close())
if successCount.Load() != 2 {
t.Errorf("expected 2 successful callbacks, got %d", successCount.Load())
}
}
func TestFeatureFlagsWithNoPersonalApiKey(t *testing.T) {
// silence Errorf by tossing them in channel and not reading back
errchan := make(chan error, 1)
defer close(errchan)
var logged []string
client, err := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: " \n\t ",
Logger: testLogger{
logf: func(format string, args ...any) {
logged = append(logged, fmt.Sprintf(format, args...))
},
errorf: t.Logf,
},
Callback: testCallback{
func(m APIMessage) {},
func(m APIMessage, e error) { errchan <- e },
},
})
require.NoError(t, err)
require.ErrorIs(t, client.ReloadFeatureFlags(), ErrNoPersonalAPIKey)
payload, err := client.GetRemoteConfigPayload("test-flag")
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
require.Empty(t, payload)
localFlag, err := client.GetFeatureFlag(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "test-user",
OnlyEvaluateLocally: true,
})
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
require.Nil(t, localFlag)
localFlags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
DistinctId: "test-user",
OnlyEvaluateLocally: true,
})
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
require.Nil(t, localFlags)
localEvaluations, err := client.EvaluateFlags(EvaluateFlagsPayload{
DistinctId: "test-user",
OnlyEvaluateLocally: true,
})
require.ErrorIs(t, err, ErrNoPersonalAPIKey)
require.NotNil(t, localEvaluations)
require.Empty(t, localEvaluations.Keys())
joinedLogs := strings.Join(logged, "\n")
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; ReloadFeatureFlags requires a PersonalApiKey.")
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; GetRemoteConfigPayload requires a PersonalApiKey.")
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; GetFeatureFlagResult requires a PersonalApiKey.")
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; GetAllFlags requires a PersonalApiKey.")
require.Contains(t, joinedLogs, "PostHog personal_api_key is not configured; EvaluateFlags requires a PersonalApiKey.")
}
func TestFeatureFlagPublicMethodsKeepDistinctIDConfigError(t *testing.T) {
client, err := NewWithConfig("test-api-key", Config{})
require.NoError(t, err)
defer client.Close()
_, err = client.IsFeatureEnabled(FeatureFlagPayload{Key: "test-flag"})
assertDistinctIDConfigError(t, err)
_, err = client.GetFeatureFlag(FeatureFlagPayload{Key: "test-flag"})
assertDistinctIDConfigError(t, err)
_, err = client.GetFeatureFlagResult(FeatureFlagPayload{Key: "test-flag"})
assertDistinctIDConfigError(t, err)
_, err = client.GetFeatureFlagPayload(FeatureFlagPayload{Key: "test-flag"})
assertDistinctIDConfigError(t, err)
_, err = client.GetAllFlags(FeatureFlagPayloadNoKey{})
assertDistinctIDConfigError(t, err)
}
func assertDistinctIDConfigError(t *testing.T, err error) {
t.Helper()
var configErr ConfigError
require.ErrorAs(t, err, &configErr)
require.Equal(t, "DistinctId required", configErr.Reason)
require.Equal(t, "Distinct Id", configErr.Field)
}
func TestIsFeatureEnabled(t *testing.T) {
tests := []struct {
name string
flagConfig FeatureFlagPayload
mockResponse string
expectedResult any
expectedError string
}{
{
name: "Feature flag is enabled",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": true}}`,
expectedResult: true,
},
{
name: "Feature flag is disabled",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user456",
},
mockResponse: `{"featureFlags": {"test-flag": false}}`,
expectedResult: false,
},
{
name: "Feature flag is a string 'true'",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user789",
},
mockResponse: `{"featureFlags": {"test-flag": "true"}}`,
expectedResult: "true",
},
{
name: "Feature flag is a string 'false'",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user101",
},
mockResponse: `{"featureFlags": {"test-flag": "false"}}`,
expectedResult: "false",
},
{
name: "Feature flag is a variant string",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user202",
},
mockResponse: `{"featureFlags": {"test-flag": "variant-a"}}`,
expectedResult: "variant-a",
},
{
name: "Feature flag doesn't exist",
flagConfig: FeatureFlagPayload{
Key: "non-existent-flag",
DistinctId: "user303",
},
mockResponse: `{"featureFlags": {}}`,
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags/" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.mockResponse))
} else {
t.Errorf("Unexpected request to %s", r.URL.Path)
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
result, err := client.IsFeatureEnabled(tt.flagConfig)
if tt.expectedError != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%v'", tt.expectedError, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if result != tt.expectedResult {
t.Errorf("Expected result %v, got %v", tt.expectedResult, result)
}
}
})
}
}
func TestDeviceIdInFlagsRequest(t *testing.T) {
t.Run("GetFeatureFlag passes device_id when provided", func(t *testing.T) {
var requestData FlagsRequestData
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &requestData)
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
defer client.Close()
deviceId := "test-device-123"
_, err := client.GetFeatureFlag(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user-123",
DeviceId: &deviceId,
})
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if requestData.DeviceId == nil {
t.Error("Expected device_id to be set in request, got nil")
} else if *requestData.DeviceId != "test-device-123" {
t.Errorf("Expected device_id 'test-device-123', got: %s", *requestData.DeviceId)
}
})
t.Run("GetFeatureFlag omits device_id when nil", func(t *testing.T) {
var receivedBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
defer client.Close()
_, err := client.GetFeatureFlag(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user-123",
})
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if strings.Contains(receivedBody, "device_id") {
t.Errorf("Expected request to NOT contain device_id when nil, got: %s", receivedBody)
}
})
t.Run("GetAllFlags passes device_id when provided", func(t *testing.T) {
var requestData FlagsRequestData
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &requestData)
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
defer client.Close()
deviceId := "test-device-456"
_, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
DistinctId: "user-123",
DeviceId: &deviceId,
})
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if requestData.DeviceId == nil {
t.Error("Expected device_id to be set in request, got nil")
} else if *requestData.DeviceId != "test-device-456" {
t.Errorf("Expected device_id 'test-device-456', got: %s", *requestData.DeviceId)
}
})
t.Run("GetFeatureFlagPayload passes device_id when provided", func(t *testing.T) {
var requestData FlagsRequestData
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &requestData)
w.Write([]byte(`{"featureFlags": {"test-flag": true}, "featureFlagPayloads": {"test-flag": "payload-value"}}`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
defer client.Close()
deviceId := "test-device-789"
_, err := client.GetFeatureFlagPayload(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user-123",
DeviceId: &deviceId,
})
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if requestData.DeviceId == nil {
t.Error("Expected device_id to be set in request, got nil")
} else if *requestData.DeviceId != "test-device-789" {
t.Errorf("Expected device_id 'test-device-789', got: %s", *requestData.DeviceId)
}
})
t.Run("Capture SendFeatureFlagsOptions passes device_id when provided", func(t *testing.T) {
var requestData FlagsRequestData
received := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/flags" || r.URL.Path == "/flags/":
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &requestData); err != nil {
t.Errorf("Failed to parse request body: %v", err)
}
close(received)
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
case strings.HasPrefix(r.URL.Path, "/flags/definitions"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"flags":[]}`))
case strings.HasPrefix(r.URL.Path, "/batch"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
default:
t.Errorf("Unexpected request to %s", r.URL.Path)
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
PersonalApiKey: "personal-key",
BatchSize: 1,
now: mockTime,
})
defer client.Close()
deviceId := "test-device-456"
err := client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: &SendFeatureFlagsOptions{
DeviceId: &deviceId,
},
})
if err != nil {
t.Fatal(err)
}
select {
case <-received:
case <-time.After(2 * time.Second):
t.Fatal("Timed out waiting for flags request")
}
if requestData.DeviceId == nil {
t.Error("Expected device_id to be set in request, got nil")
} else if *requestData.DeviceId != "test-device-456" {
t.Errorf("Expected device_id 'test-device-456', got: %s", *requestData.DeviceId)
}
})
}
func TestGetFeatureFlagPayloadWithNoPersonalApiKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
t.Errorf("client called an endpoint it shouldn't have: %s", r.URL.Path)
}
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
func(m APIMessage) {},
func(m APIMessage, e error) {},
},
})
defer client.Close()
// Test GetFeatureFlagPayload single scenario
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{
Key: "enabled-flag",
DistinctId: "test-user",
})
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
// Check that the flag payload is as expected (should match the value in the fixture)
expectedPayload := "{\"foo\": 1}"
if payload != expectedPayload {
t.Errorf("Expected flag payload %v, got: %v", expectedPayload, payload)
}
// Test a bunch of GetFeatureFlagPayload scenarios
tests := []struct {
name string
flagConfig FeatureFlagPayload
mockResponse string
expectedValue string
expectedError string
}{
{
name: "Flag exists and there is a payload",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": true}, "featureFlagPayloads": {"test-flag": "{\"test\": 1}"}}`,
expectedValue: "{\"test\": 1}",
},
{
name: "Flag exists and payload object is not present",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": false}}`,
expectedValue: "",
},
{
name: "Flag exists and there is no payload",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": false}, "featureFlagPayloads": {}}`,
expectedValue: "",
},
{
name: "Flag doesn't exist",
flagConfig: FeatureFlagPayload{
Key: "non-existent-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"other-flag": true}}`,
expectedValue: "",
},
{
name: "Empty response",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{}`,
expectedValue: "",
},
{
name: "Invalid JSON response",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{invalid-json}`,
expectedError: "error parsing response from /flags/",
},
{
name: "Non-200 status code",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: ``,
expectedError: "unexpected status code from /flags/: 500",
},
{
name: "With groups and properties",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
Groups: Groups{
"company": "test-company",
},
PersonProperties: Properties{
"plan": "enterprise",
},
GroupProperties: map[string]Properties{
"company": {
"size": "large",
},
},
},
mockResponse: `{"featureFlags": {"test-flag": "enterprise-variant"}, "featureFlagPayloads": {"test-flag": "{\"test\": 3}"}}`,
expectedValue: "{\"test\": 3}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request method and path
if r.Method != "POST" || r.URL.Path != "/flags/" {
t.Errorf("Expected POST /flags/, got %s %s", r.Method, r.URL.Path)
}
// Check headers
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type: application/json, got %s", r.Header.Get("Content-Type"))
}
if !strings.HasPrefix(r.Header.Get("User-Agent"), "insights-go/") {
t.Errorf("Unexpected User-Agent: %s", r.Header.Get("User-Agent"))
}
// Check request body
body, _ := io.ReadAll(r.Body)
var requestData FlagsRequestData
json.Unmarshal(body, &requestData)
if requestData.DistinctId != tt.flagConfig.DistinctId {
t.Errorf("Expected distinctId %s, got %s", tt.flagConfig.DistinctId, requestData.DistinctId)
}
// Send mock response
if tt.expectedError == "unexpected status code from /flags/: 500" {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
value, err := client.GetFeatureFlagPayload(tt.flagConfig)
if tt.expectedError != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%v'", tt.expectedError, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if value != tt.expectedValue {
t.Errorf("Expected value %v, got %v", tt.expectedValue, value)
}
}
})
}
}
func TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
t.Errorf("client called an endpoint it shouldn't have: %s", r.URL.Path)
}
}))
defer server.Close()
// Capture events via callback
var capturedEvent *CaptureInApi
var mu sync.Mutex
eventCaptured := make(chan struct{}, 1)
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
BatchSize: 1, // Send immediately
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
func(m APIMessage) {
if capture, ok := m.(CaptureInApi); ok {
mu.Lock()
capturedEvent = &capture
mu.Unlock()
select {
case eventCaptured <- struct{}{}:
default:
}
}
},
func(m APIMessage, e error) {},
},
})
defer client.Close()
// Test GetFeatureFlag single scenario
flagValue, err := client.GetFeatureFlag(FeatureFlagPayload{
Key: "beta-feature",
DistinctId: "test-user",
})
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
// Check that the flag value is as expected (should match the value in the fixture)
expectedValue := "decide-fallback-value"
if flagValue != expectedValue {
t.Errorf("Expected flag value %v, got: %v", expectedValue, flagValue)
}
// Wait for event to be captured
select {
case <-eventCaptured:
case <-time.After(time.Second):
t.Fatal("Timed out waiting for captured event")
}
mu.Lock()
lastEvent := capturedEvent
mu.Unlock()
if lastEvent == nil || lastEvent.Event != "$feature_flag_called" {
t.Errorf("Expected a $feature_flag_called event, got: %v", lastEvent)
}
// Check that the properties of the captured event match the response from /flags
if lastEvent != nil {
if lastEvent.Properties["$feature_flag"] != "beta-feature" {
t.Errorf("Expected feature flag key 'beta-feature', got: %v", lastEvent.Properties["$feature_flag"])
}
if lastEvent.Properties["$feature_flag_response"] != expectedValue {
t.Errorf("Expected feature flag response %v, got: %v", expectedValue, lastEvent.Properties["$feature_flag_response"])
}
if lastEvent.Properties["locally_evaluated"] != false {
t.Errorf("Expected locally_evaluated to be false for remote evaluation, got: %v", lastEvent.Properties["locally_evaluated"])
}
}
// Test a bunch of GetFeatureFlag scenarios
tests := []struct {
name string
flagConfig FeatureFlagPayload
mockResponse string
expectedValue any
expectedError string
}{
{
name: "Flag exists and is true",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": true}}`,
expectedValue: true,
},
{
name: "Flag exists and is false",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": false}}`,
expectedValue: false,
},
{
name: "Flag exists with string value",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"test-flag": "variant-a"}}`,
expectedValue: "variant-a",
},
{
name: "Flag doesn't exist",
flagConfig: FeatureFlagPayload{
Key: "non-existent-flag",
DistinctId: "user123",
},
mockResponse: `{"featureFlags": {"other-flag": true}}`,
expectedValue: false,
},
{
name: "Empty response",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{}`,
expectedValue: false,
},
{
name: "Invalid JSON response",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: `{invalid-json}`,
expectedError: "error parsing response from /flags/",
},
{
name: "Non-200 status code",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
},
mockResponse: ``,
expectedError: "unexpected status code from /flags/: 500",
},
{
name: "With groups and properties",
flagConfig: FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
Groups: Groups{
"company": "test-company",
},
PersonProperties: Properties{
"plan": "enterprise",
},
GroupProperties: map[string]Properties{
"company": {
"size": "large",
},
},
},
mockResponse: `{"featureFlags": {"test-flag": "enterprise-variant"}}`,
expectedValue: "enterprise-variant",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request method and path
if r.Method != "POST" || r.URL.Path != "/flags/" {
t.Errorf("Expected POST /flags/, got %s %s", r.Method, r.URL.Path)
}
// Check headers
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type: application/json, got %s", r.Header.Get("Content-Type"))
}
if !strings.HasPrefix(r.Header.Get("User-Agent"), "insights-go/") {
t.Errorf("Unexpected User-Agent: %s", r.Header.Get("User-Agent"))
}
// Check request body
body, _ := io.ReadAll(r.Body)
var requestData FlagsRequestData
json.Unmarshal(body, &requestData)
if requestData.DistinctId != tt.flagConfig.DistinctId {
t.Errorf("Expected distinctId %s, got %s", tt.flagConfig.DistinctId, requestData.DistinctId)
}
// Send mock response
if tt.expectedError == "unexpected status code from /flags/: 500" {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
value, err := client.GetFeatureFlag(tt.flagConfig)
if tt.expectedError != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%v'", tt.expectedError, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if value != tt.expectedValue {
t.Errorf("Expected value %v, got %v", tt.expectedValue, value)
}
}
})
}
}
func TestGetAllFeatureFlagsWithNoPersonalApiKey(t *testing.T) {
tests := []struct {
name string
flagConfig FeatureFlagPayloadNoKey
mockResponse string
expectedFlags map[string]any
expectedError string
}{
{
name: "Multiple feature flags",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user123",
},
mockResponse: `{
"featureFlags": {
"flag1": true,
"flag2": false,
"flag3": "variant-a"
}
}`,
expectedFlags: map[string]any{
"flag1": true,
"flag2": false,
"flag3": "variant-a",
},
},
{
name: "No feature flags",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user456",
},
mockResponse: `{
"featureFlags": {}
}`,
expectedFlags: map[string]any{},
},
{
name: "Invalid JSON response",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user789",
},
mockResponse: `{invalid-json}`,
expectedError: "error parsing response from /flags/",
},
{
name: "Non-200 status code",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user101",
},
mockResponse: ``,
expectedError: "unexpected status code from /flags/: 500",
},
{
name: "With groups and properties",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user102",
Groups: Groups{
"company": "test-company",
},
PersonProperties: Properties{
"plan": "enterprise",
},
GroupProperties: map[string]Properties{
"company": {
"size": "large",
},
},
},
mockResponse: `{
"featureFlags": {
"enterprise_flag": true,
"company_size_flag": "large"
}
}`,
expectedFlags: map[string]any{
"enterprise_flag": true,
"company_size_flag": "large",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check request method and path
if r.Method != "POST" || r.URL.Path != "/flags/" {
t.Errorf("Expected POST /flags/, got %s %s", r.Method, r.URL.Path)
}
// Check headers
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type: application/json, got %s", r.Header.Get("Content-Type"))
}
if !strings.HasPrefix(r.Header.Get("User-Agent"), "insights-go/") {
t.Errorf("Unexpected User-Agent: %s", r.Header.Get("User-Agent"))
}
// Check request body
body, _ := io.ReadAll(r.Body)
var requestData FlagsRequestData
json.Unmarshal(body, &requestData)
if requestData.DistinctId != tt.flagConfig.DistinctId {
t.Errorf("Expected distinctId %s, got %s", tt.flagConfig.DistinctId, requestData.DistinctId)
}
// Send mock response
if tt.expectedError == "unexpected status code from /flags/: 500" {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.mockResponse))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
// Note: No PersonalApiKey is set, so it will fall back to using the flags endpoint
})
flags, err := client.GetAllFlags(tt.flagConfig)
if tt.expectedError != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectedError) {
t.Errorf("Expected error containing '%s', got '%v'", tt.expectedError, err)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !reflect.DeepEqual(flags, tt.expectedFlags) {
t.Errorf("Expected flags %v, got %v", tt.expectedFlags, flags)
}
}
})
}
}
func TestGetFeatureFlagPayloadWithPersonalKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
t.Fatal("expected local evaluations endpoint to be called")
}
w.Write([]byte(fixture("test-api-feature-flag.json")))
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: "some very secret key",
Endpoint: server.URL,
})
defer client.Close()
payload, checkErr := client.GetFeatureFlagPayload(
FeatureFlagPayload{
Key: "simpleFlag",
DistinctId: "hey",
},
)
expectedPayload := "{\"test\": 1}"
if checkErr != nil || payload != expectedPayload {
t.Errorf("expected payload %v, got %v", expectedPayload, payload)
}
}
func TestGetFeatureFlagPayloadWithPersonalKey_LocalComputationFailure(t *testing.T) {
apiCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if apiCalls == 0 && (r.URL.Path == "/flags" || r.URL.Path == "/flags/") {
t.Fatal("expected local evaluations endpoint to be called first")
} else if apiCalls == 1 && strings.HasPrefix(r.URL.Path, "/flags/definitions") {
t.Fatal("expected flags endpoint to be called second")
}
if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
w.Write([]byte(fixture("test-api-feature-flag.json")))
} else {
w.Write([]byte(fixture("test-flags-v3.json")))
}
apiCalls++
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: "some very secret key",
Endpoint: server.URL,
})
defer client.Close()
payload, checkErr := client.GetFeatureFlagPayload(
FeatureFlagPayload{
Key: "continuation-flag",
DistinctId: "hey",
},
)
if checkErr != nil {
t.Error("expected no error, got", checkErr)
}
expectedPayload := "{\"foo\": \"bar\"}"
if payload != expectedPayload {
t.Errorf("expected payload %v, got %v", expectedPayload, payload)
}
}
func TestSimpleFlagOld(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(fixture("test-api-feature-flag.json")))
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: "some very secret key",
Endpoint: server.URL,
})
defer client.Close()
isEnabled, checkErr := client.IsFeatureEnabled(
FeatureFlagPayload{
Key: "simpleFlag",
DistinctId: "hey",
},
)
if checkErr != nil || isEnabled != true {
t.Errorf("simple flag with null rollout percentage should be on for everyone")
}
}
func TestSimpleFlagCalculation(t *testing.T) {
require.True(t, checkIfSimpleFlagEnabled("a", "b", 42))
require.False(t, checkIfSimpleFlagEnabled("a", "b", 40))
}
func TestComplexFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
w.Write([]byte(fixture("test-api-feature-flag.json")))
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
t.Errorf("client called an endpoint it shouldn't have")
}
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: "some very secret key",
Endpoint: server.URL,
})
defer client.Close()
isEnabled, checkErr := client.IsFeatureEnabled(
FeatureFlagPayload{
Key: "enabled-flag",
DistinctId: "hey",
},
)
if checkErr != nil || isEnabled != true {
t.Errorf("flag listed in /flags/ response should be marked as enabled")
}
flagValue, valueErr := client.GetFeatureFlag(
FeatureFlagPayload{
Key: "enabled-flag",
DistinctId: "hey",
},
)
if valueErr != nil || flagValue != true {
t.Errorf("flag listed in /flags/ response should be true")
}
payload, valueErr := client.GetFeatureFlagPayload(
FeatureFlagPayload{
Key: "enabled-flag",
DistinctId: "hey",
},
)
if valueErr != nil || payload != "{\"test\": 1}" {
t.Errorf(`flag listed in /flags/ response should be "{\"test\": 1}"`)
}
}
func TestMultiVariateFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
w.Write([]byte("{}"))
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
t.Errorf("client called an endpoint it shouldn't have")
}
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: "some very secret key",
Endpoint: server.URL,
})
defer client.Close()
isEnabled, checkErr := client.IsFeatureEnabled(
FeatureFlagPayload{
Key: "multi-variate-flag",
DistinctId: "hey",
},
)
if checkErr != nil || isEnabled == false {
t.Errorf("flag listed in /flags/ response should be marked as enabled")
}
flagValue, err := client.GetFeatureFlag(
FeatureFlagPayload{
Key: "multi-variate-flag",
DistinctId: "hey",
},
)
if err != nil || flagValue != "hello" {
t.Errorf("flag listed in /flags/ response should have value 'hello'")
}
payload, err := client.GetFeatureFlagPayload(
FeatureFlagPayload{
Key: "multi-variate-flag",
DistinctId: "hey",
},
)
if err != nil || payload != "this is the payload" {
t.Errorf("flag listed in /flags/ response should have value 'this is the payload'")
}
}
func TestDisabledFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if strings.HasPrefix(r.URL.Path, "/flags/definitions") {
w.Write([]byte("{}"))
} else if !strings.HasPrefix(r.URL.Path, "/batch") {
t.Errorf("client called an endpoint it shouldn't have")
}
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
PersonalApiKey: "some very secret key",
Endpoint: server.URL,
})
defer client.Close()
isEnabled, checkErr := client.IsFeatureEnabled(
FeatureFlagPayload{
Key: "disabled-flag",
DistinctId: "hey",
},
)
if checkErr != nil || isEnabled == true {
t.Errorf("flag listed in /flags/ response should be marked as disabled")
}
flagValue, err := client.GetFeatureFlag(
FeatureFlagPayload{
Key: "disabled-flag",
DistinctId: "hey",
},
)
if err != nil || flagValue != false {
t.Errorf("flag listed in /flags/ response should have value 'false'")
}
payload, err := client.GetFeatureFlagPayload(
FeatureFlagPayload{
Key: "disabled-flag",
DistinctId: "hey",
},
)
if err != nil || payload != "" {
t.Errorf("flag listed in /flags/ response should have value ''")
}
}
func TestCaptureSendFlags(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(fixture("test-api-feature-flag.json")))
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Verbose: true,
Logger: toLogger(t),
BatchSize: 1,
now: mockTime,
PersonalApiKey: "some very secret key",
})
defer client.Close()
// Without this call client.Close hangs forever
// Ref: https://github.com/hanzoai/insights-go/issues/28
client.IsFeatureEnabled(
FeatureFlagPayload{
Key: "simpleFlag",
DistinctId: "hey",
},
)
err := client.Enqueue(Capture{
Event: "Download",
DistinctId: "123456",
SendFeatureFlags: SendFeatureFlags(true),
})
if err != nil {
t.Fatal(err)
}
}
func TestCaptureSendFeatureFlagsOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(fixture("test-api-feature-flag.json")))
}))
defer server.Close()
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Endpoint: server.URL,
Verbose: true,
Logger: toLogger(t),
BatchSize: 1,
now: mockTime,
PersonalApiKey: "some very secret key",
})
defer client.Close()
// Test with SendFeatureFlagsOptions struct
t.Run("SendFeatureFlagsOptions with PersonProperties", func(t *testing.T) {
err := client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: &SendFeatureFlagsOptions{
PersonProperties: NewProperties().Set("plan", "premium"),
},
})
if err != nil {
t.Fatal(err)
}
})
t.Run("SendFeatureFlagsOptions with GroupProperties", func(t *testing.T) {
err := client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: &SendFeatureFlagsOptions{
GroupProperties: map[string]Properties{
"company": NewProperties().Set("name", "Hanzo AI"),
},
},
})
if err != nil {
t.Fatal(err)
}
})
t.Run("SendFeatureFlagsOptions with OnlyEvaluateLocally", func(t *testing.T) {
err := client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: &SendFeatureFlagsOptions{
OnlyEvaluateLocally: true,
PersonProperties: NewProperties().Set("plan", "premium"),
},
})
if err != nil {
t.Fatal(err)
}
})
t.Run("SendFeatureFlags with nil should not crash", func(t *testing.T) {
err := client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: nil,
})
if err != nil {
t.Fatal(err)
}
})
t.Run("SendFeatureFlags backwards compatibility with bool", func(t *testing.T) {
err := client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: SendFeatureFlags(true),
})
if err != nil {
t.Fatal(err)
}
err = client.Enqueue(Capture{
Event: "test_event",
DistinctId: "test_user",
SendFeatureFlags: SendFeatureFlags(false),
})
if err != nil {
t.Fatal(err)
}
})
}
func TestSendFeatureFlagsHelperMethods(t *testing.T) {
t.Run("shouldSendFeatureFlags with bool true", func(t *testing.T) {
capture := Capture{SendFeatureFlags: SendFeatureFlags(true)}
if !capture.shouldSendFeatureFlags() {
t.Error("Expected shouldSendFeatureFlags to return true for bool true")
}
})
t.Run("shouldSendFeatureFlags with bool false", func(t *testing.T) {
capture := Capture{SendFeatureFlags: SendFeatureFlags(false)}
if capture.shouldSendFeatureFlags() {
t.Error("Expected shouldSendFeatureFlags to return false for bool false")
}
})
t.Run("shouldSendFeatureFlags with nil", func(t *testing.T) {
capture := Capture{SendFeatureFlags: nil}
if capture.shouldSendFeatureFlags() {
t.Error("Expected shouldSendFeatureFlags to return false for nil")
}
})
t.Run("shouldSendFeatureFlags with SendFeatureFlagsOptions", func(t *testing.T) {
capture := Capture{SendFeatureFlags: &SendFeatureFlagsOptions{}}
if !capture.shouldSendFeatureFlags() {
t.Error("Expected shouldSendFeatureFlags to return true for non-nil options")
}
})
t.Run("getFeatureFlagsOptions with bool", func(t *testing.T) {
capture := Capture{SendFeatureFlags: SendFeatureFlags(true)}
if capture.getFeatureFlagsOptions() != nil {
t.Error("Expected getFeatureFlagsOptions to return nil for bool")
}
})
t.Run("getFeatureFlagsOptions with SendFeatureFlagsOptions", func(t *testing.T) {
opts := &SendFeatureFlagsOptions{OnlyEvaluateLocally: true}
capture := Capture{SendFeatureFlags: opts}
result := capture.getFeatureFlagsOptions()
if result != opts {
t.Error("Expected getFeatureFlagsOptions to return the same options")
}
if !result.OnlyEvaluateLocally {
t.Error("Expected OnlyEvaluateLocally to be true")
}
})
t.Run("getFeatureFlagsOptions with nil", func(t *testing.T) {
capture := Capture{SendFeatureFlags: nil}
if capture.getFeatureFlagsOptions() != nil {
t.Error("Expected getFeatureFlagsOptions to return nil for nil")
}
})
}
func TestFeatureFlagQuotaLimits(t *testing.T) {
t.Run("flags endpoint quota limited", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"featureFlags": {"test-flag": true},
"featureFlagPayloads": {"test-flag": "test-payload"},
"quota_limited": ["feature_flags"]
}`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
Endpoint: server.URL,
})
defer client.Close()
// Test GetFeatureFlag
value, err := client.GetFeatureFlag(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
})
if err != nil {
t.Error("Expected no error, got", err)
}
if value != false {
t.Error("Expected false when quota limited, got", value)
}
// Test GetFeatureFlagPayload
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
})
if err != nil {
t.Error("Expected no error, got", err)
}
if payload != "" {
t.Error("Expected empty string when quota limited, got", payload)
}
// Test GetAllFlags
flags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
DistinctId: "user123",
})
if err != nil {
t.Error("Expected no error, got", err)
}
if len(flags) != 0 {
t.Error("Expected empty map when quota limited, got", flags)
}
})
t.Run("local evaluation endpoint quota limited", 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.WriteHeader(http.StatusPaymentRequired)
w.Write([]byte(`{
"type": "quota_limited",
"detail": "You have exceeded your feature flag request quota",
"code": "payment_required"
}`))
} else if r.URL.Path == "/flags" || r.URL.Path == "/flags/" {
// Mock the flags endpoint as well since it's used as fallback
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"featureFlags": {},
"featureFlagPayloads": {}
}`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
PersonalApiKey: "test-personal-key",
Endpoint: server.URL,
})
defer client.Close()
// Test GetFeatureFlag
value, err := client.GetFeatureFlag(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
})
if err != nil {
t.Error("Expected no error, got", err)
}
if value != false {
t.Error("Expected false when quota limited, got", value)
}
// Test GetFeatureFlagPayload
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{
Key: "test-flag",
DistinctId: "user123",
})
if err != nil {
t.Error("Expected no error, got", err)
}
if payload != "" {
t.Error("Expected empty string when quota limited, got", payload)
}
// Test GetAllFlags
flags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
DistinctId: "user123",
})
if err != nil {
t.Error("Expected no error, got", err)
}
if len(flags) != 0 {
t.Error("Expected empty map when quota limited, got", flags)
}
})
}
func TestClient_GetRemoteConfigPayload_IncludesTokenParameter(t *testing.T) {
t.Run("includes project API key token in remote config URL", func(t *testing.T) {
var remoteConfigCalled bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle the initial feature flag definitions request
if strings.Contains(r.URL.Path, "/flags/definitions") {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"flags": [], "group_type_mapping": {}}`))
return
}
// Handle the remote config request
if strings.Contains(r.URL.Path, "/remote_config") {
remoteConfigCalled = true
// Verify the URL includes the token parameter
expectedPath := "/api/projects/@current/feature_flags/test-flag/remote_config"
if r.URL.Path != expectedPath {
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
}
// Verify the token parameter is present with the correct value
token := r.URL.Query().Get("token")
if token != "test-api-key" {
t.Errorf("Expected token 'test-api-key', got '%s'", token)
}
// Verify Authorization header uses personal API key
authHeader := r.Header.Get("Authorization")
expectedAuth := "Bearer test-personal-key"
if authHeader != expectedAuth {
t.Errorf("Expected Authorization header '%s', got '%s'", expectedAuth, authHeader)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`"{\"foo\": \"bar\",\"baz\": 42}"`))
}
}))
defer server.Close()
client, _ := NewWithConfig("test-api-key", Config{
PersonalApiKey: "test-personal-key",
Endpoint: server.URL,
})
defer client.Close()
payload, err := client.GetRemoteConfigPayload("test-flag")
if err != nil {
t.Error("Expected no error, got", err)
}
if !remoteConfigCalled {
t.Error("Expected remote config endpoint to be called")
}
expected := `{"foo": "bar","baz": 42}`
if payload != expected {
t.Errorf("Expected payload '%s', got '%s'", expected, payload)
}
})
}