mirror of
https://github.com/hanzoai/insights-go.git
synced 2026-09-30 22:46:12 +00:00
* intitial refactor: - Add Context-based graceful shutdown and drain phase for enqueued requests - Use buffered channels vs. mutex for concurrency mgmt. - Use atomic.Pointer vs. locks for FF mgmt - Use Callback ifc vs. lossy internal tracking for unit test result validation - Add new APIs mirroring current that accept caller's Context to control operation timeouts * expose batch submission timeout with default equiv to old hardcoded timeout * restructure event submission and batching pipeline * overhaul tests; implement new stress tests and address coverage gaps * parallelize tests where safe to do so (3x CI time savings!) * introduce faster pure Go JSON seralization library * more refactors: unit tests & benchmarks * DRY up shutdown req drain and prepareForSend serializer * use go-json in CLI utils too; remove redundant helpers * move error type to proper pkg * DRY up loop() and fix deadlock in old test case * normalize CI and Makefile test and benchmark target behavviors * more realistic feature flags benchmark * replace leaky time.After pattern with idiomatic safer Timers * internal queue size minimums decoupled from batch and worker configs * remove dead comments * fix test bug * update default http.Transport cfg to avoid conn pool as bottleneck; update batch enqueue limiting strategy and tests/benches * adjust stress test defaults to match prod * update flaky tests and rm stale file * make graceful shutdown timeout opt-in for now * fix tests that reverted to hold helper utils during rebase * fix flaky test due to overhead from -race detector now default in test flags * add more graceful shutdown test cases
2151 lines
56 KiB
Go
2151 lines
56 KiB
Go
package posthog
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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": "PostHog 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": "posthog-go",
|
|
// "library_version": "1.0.0",
|
|
// "properties": {
|
|
// "$geoip_disable": true,
|
|
// "$lib": "posthog-go",
|
|
// "$lib_version": "1.0.0",
|
|
// "application": "PostHog 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{
|
|
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/posthog-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{Alias: "A", DistinctId: "B"},
|
|
&tv,
|
|
},
|
|
|
|
"identify": {
|
|
strings.TrimSpace(fixture("test-enqueue-identify.json")),
|
|
Identify{
|
|
DistinctId: "B",
|
|
Properties: Properties{"email": "[email protected]"},
|
|
},
|
|
&tv,
|
|
},
|
|
"identify-default-geoip": {
|
|
strings.TrimSpace(fixture("test-enqueue-identify.json")),
|
|
Identify{
|
|
DistinctId: "B",
|
|
Properties: Properties{"email": "[email protected]"},
|
|
},
|
|
nil,
|
|
},
|
|
|
|
"groupIdentify": {
|
|
strings.TrimSpace(fixture("test-enqueue-group-identify.json")),
|
|
GroupIdentify{
|
|
DistinctId: "$organization_id:5",
|
|
Type: "organization",
|
|
Key: "id:5",
|
|
Properties: Properties{},
|
|
},
|
|
&tv,
|
|
},
|
|
|
|
"capture": {
|
|
strings.TrimSpace(fixture("test-enqueue-capture.json")),
|
|
Capture{
|
|
Event: "Download",
|
|
DistinctId: "123456",
|
|
Properties: Properties{
|
|
"application": "PostHog Go",
|
|
"version": "1.0.0",
|
|
"platform": "macos", // :)
|
|
},
|
|
SendFeatureFlags: SendFeatureFlags(false),
|
|
},
|
|
&f,
|
|
},
|
|
|
|
"captureWithDisableGeoIP": {
|
|
strings.TrimSpace(fixture("test-enqueue-capture-with-disable-geoip.json")),
|
|
Capture{
|
|
Event: "Download",
|
|
DistinctId: "123456",
|
|
Properties: Properties{
|
|
"application": "PostHog 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{Alias: "A", DistinctId: "B"},
|
|
&tv,
|
|
},
|
|
|
|
"*identify": {
|
|
strings.TrimSpace(fixture("test-enqueue-identify.json")),
|
|
&Identify{
|
|
DistinctId: "B",
|
|
Properties: Properties{"email": "[email protected]"},
|
|
},
|
|
&tv,
|
|
},
|
|
|
|
"*groupIdentify": {
|
|
strings.TrimSpace(fixture("test-enqueue-group-identify.json")),
|
|
&GroupIdentify{
|
|
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": "PostHog 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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: *posthog.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{
|
|
Event: "Download",
|
|
DistinctId: "123456",
|
|
Properties: Properties{
|
|
"application": "PostHog 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{
|
|
Event: "Download",
|
|
DistinctId: "123456",
|
|
Properties: Properties{
|
|
"application": "PostHog 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{
|
|
Event: "Download",
|
|
DistinctId: "123456",
|
|
Properties: Properties{
|
|
"application": "PostHog 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()
|
|
|
|
for i := 0; i < 5; i++ {
|
|
client.Enqueue(Capture{
|
|
Event: "Download",
|
|
DistinctId: "123456",
|
|
Properties: Properties{
|
|
"application": "PostHog 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 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"), "posthog-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"), "posthog-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"), "posthog-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/PostHog/posthog-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", "PostHog"),
|
|
},
|
|
},
|
|
})
|
|
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)
|
|
}
|
|
})
|
|
}
|