Files
insights-go/insights_test.go
T
zeekay b4e1366340 rebrand: export Insights types instead of PostHog
Rename Go package from posthog to insights across all 80+ files.
Consumers now import as `insights "github.com/hanzoai/insights-go"`
and use insights.Client, insights.Config, insights.Capture, etc.

- Rename posthog.go -> insights.go, posthog_test.go -> insights_test.go
- Update package declaration from posthog to insights in all .go files
- Update SDKName from posthog-go to insights-go
- Update DefaultEndpoint from app.posthog.com to insights.hanzo.ai
- Update all FieldError/ConfigError type strings
- Update User-Agent headers, log prefixes, comments, and docs
- Update JSON test fixtures to match new SDK name
- Update CLI, examples, extras, and sdk_compliance_adapter
- Fix pre-existing go vet warnings (non-constant format strings)
2026-03-13 19:54:58 -07:00

2495 lines
66 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/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, ...interface{})
errorf func(string, ...interface{})
}
func (l testLogger) Debugf(format string, args ...interface{}) {
if l.logf != nil {
l.logf(format, args...)
}
}
func (l testLogger) Logf(format string, args ...interface{}) {
if l.logf != nil {
l.logf(format, args...)
}
}
func (l testLogger) Warnf(format string, args ...interface{}) {
if l.logf != nil {
l.logf(format, args...)
}
}
func (l testLogger) Errorf(format string, args ...interface{}) {
if l.errorf != nil {
l.errorf(format, args...)
}
}
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, "/api/feature_flag/local_evaluation") {
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 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 interface{}
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",
"platform": "macos", // :)
},
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",
"platform": "macos", // :)
},
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",
"platform": "macos", // :)
},
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",
"platform": "macos", // :)
},
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
}
if res := string(<-body); res != test.ref {
t.Errorf("%s: invalid response:\n- expected %s\n- received: %s", name, test.ref, res)
}
})
}
}
// 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",
"platform": "macos", // :)
},
SendFeatureFlags: SendFeatureFlags(false),
})
// Will flush in 100 milliseconds
if res := string(<-body); ref != res {
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
}
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",
"platform": "macos", // :)
},
SendFeatureFlags: SendFeatureFlags(false),
Timestamp: time.Date(2015, time.July, 10, 23, 0, 0, 0, time.UTC),
})
if res := string(<-body); ref != res {
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
}
}
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",
"platform": "macos", // :)
},
SendFeatureFlags: SendFeatureFlags(false),
Timestamp: time.Date(2015, time.July, 10, 23, 0, 0, 0, time.UTC),
})
if res := string(<-body); ref != res {
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
}
}
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 := 0; i < 5; i++ {
client.Enqueue(Capture{
Uuid: uuids[i],
Event: "Download",
DistinctId: "123456",
Properties: Properties{
"application": "Insights Go",
"version": i,
},
SendFeatureFlags: SendFeatureFlags(false),
})
}
if res := string(<-body); ref != res {
t.Errorf("invalid response:\n- expected %s\n- received: %s", ref, res)
}
}
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)
client, err := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
Logger: testLogger{t.Logf, t.Logf},
Callback: testCallback{
func(m APIMessage) {},
func(m APIMessage, e error) { errchan <- e },
},
})
require.NoError(t, err)
require.ErrorContains(t, client.ReloadFeatureFlags(), "no PersonalAPIKey provided")
_, err = client.GetFeatureFlags()
require.ErrorContains(t, err, "no PersonalAPIKey provided")
}
func TestIsFeatureEnabled(t *testing.T) {
tests := []struct {
name string
flagConfig FeatureFlagPayload
mockResponse string
expectedResult interface{}
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 strings.HasPrefix(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 strings.HasPrefix(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 strings.HasPrefix(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 strings.HasPrefix(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 strings.HasPrefix(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, "/api/feature_flag/local_evaluation"):
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 strings.HasPrefix(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 strings.HasPrefix(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"])
}
}
// Test a bunch of GetFeatureFlag scenarios
tests := []struct {
name string
flagConfig FeatureFlagPayload
mockResponse string
expectedValue interface{}
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]interface{}
expectedError string
}{
{
name: "Multiple feature flags",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user123",
},
mockResponse: `{
"featureFlags": {
"flag1": true,
"flag2": false,
"flag3": "variant-a"
}
}`,
expectedFlags: map[string]interface{}{
"flag1": true,
"flag2": false,
"flag3": "variant-a",
},
},
{
name: "No feature flags",
flagConfig: FeatureFlagPayloadNoKey{
DistinctId: "user456",
},
mockResponse: `{
"featureFlags": {}
}`,
expectedFlags: map[string]interface{}{},
},
{
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]interface{}{
"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 strings.HasPrefix(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 && strings.HasPrefix(r.URL.Path, "/flags") {
t.Fatal("expected local evaluations endpoint to be called first")
} else if apiCalls == 1 && strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
t.Fatal("expected flags endpoint to be called second")
}
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
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 strings.HasPrefix(r.URL.Path, "/flags") {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
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 strings.HasPrefix(r.URL.Path, "/flags") {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
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 strings.HasPrefix(r.URL.Path, "/flags") {
w.Write([]byte(fixture("test-flags-v3.json")))
} else if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
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 strings.HasPrefix(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, "/api/feature_flag/local_evaluation") {
w.WriteHeader(http.StatusPaymentRequired)
w.Write([]byte(`{
"type": "quota_limited",
"detail": "You have exceeded your feature flag request quota",
"code": "payment_required"
}`))
} else if strings.HasPrefix(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, "/api/feature_flag/local_evaluation") {
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)
}
})
}