From 76dfd1374a397c6ed8d82a4a475aa2dadd248091 Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Wed, 7 Aug 2024 19:43:26 -0400 Subject: [PATCH] feat(flags): fall back to `/decide` endpoint for `GetFeatureFlag` and `GetAllFlags` so that users can use this library without needing a personal API Key (#46) * initial commit * info about the personal api key * adding docs * logs and comments * adding changelog --- CHANGELOG.md | 8 +- README.md | 37 +++- config.go | 7 +- feature_flags_test.go | 2 +- featureflags.go | 16 +- posthog.go | 151 ++++++++++++-- posthog_test.go | 460 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 629 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbb7c6..791755c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ -# 2.0.0 - 2022-08-15 +# Changelog + +## 2.0.1 - 2024-08-07 + +1. The client will fall back to the `/decide` endpoint when evaluating feature flags if the user does not wish to provide a PersonalApiKey. This fixes an issue where users were unable to use this SDK without providing a PersonalApiKey. This fallback will make feature flag usage less performant, but will save users money by not making them pay for public API access. + +## 2.0.0 - 2022-08-15 Breaking changes: diff --git a/README.md b/README.md index 95e8a94..4a263a8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,15 @@ import ( ) func main() { - client := posthog.New(os.Getenv("POSTHOG_API_KEY")) + client := posthog.New(os.Getenv("POSTHOG_API_KEY")) // This value must be set to the project API key in PostHog + // alternatively, you can do + // client, _ := posthog.NewWithConfig( + // os.Getenv("POSTHOG_API_KEY"), + // posthog.Config{ + // PersonalApiKey: "your personal API key", // Set this to your personal API token you want feature flag evaluation to be more performant. This will incur more costs, though + // Endpoint: "https://us.i.posthog.com", + // }, + // ) defer client.Close() // Capture an event @@ -54,10 +62,37 @@ func main() { Properties: posthog.NewProperties(). Set("$current_url", "https://example.com"), }) + + // Check if a feature flag is enabled + isMyFlagEnabled, err := client.IsFeatureEnabled( + FeatureFlagPayload{ + Key: "flag-key", + DistinctId: "distinct_id_of_your_user", + }) + + if isMyFlagEnabled == true { + // Do something differently for this user + } } ``` +## Testing Locally + +You can run your Go app against a local build of `posthog-go` by making the following change to your `go.mod` file for whichever your app, e.g. + +```Go +module example/posthog-go-app + +go 1.22.5 + +require github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103 + +require github.com/google/uuid v1.3.0 // indirect + +replace github.com/posthog/posthog-go => /path-to-your-local/posthog-go +``` + ## Questions? ### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) diff --git a/config.go b/config.go index 4624d69..ee8d2dd 100644 --- a/config.go +++ b/config.go @@ -18,8 +18,11 @@ type Config struct { // `DefaultEndpoint` by default. Endpoint string - // You must specify a Personal API Key to use feature flags - // More information on how to get one: https://posthog.com/docs/api/overview + // Specifying a Personal API key will make feature flag evaluation more performant, + // but it's not required for feature flags. If you don't have a personal API key, + // you can leave this field empty, and all of the relevant feature flag evaluation + // methods will still work. + // Information on how to get a personal API key: https://posthog.com/docs/api/overview PersonalApiKey string // The flushing interval of the client. Messages will be sent when they've diff --git a/feature_flags_test.go b/feature_flags_test.go index 58d56a4..fc0c927 100644 --- a/feature_flags_test.go +++ b/feature_flags_test.go @@ -325,7 +325,7 @@ func TestFlagGroup(t *testing.T) { t.Errorf("Expected personProperties to be map[region:Canada], got %s", reqBody.PersonProperties) } - groupPropertiesEquality := reflect.DeepEqual(reqBody.GroupProperties, map[string]Properties{"company": Properties{"name": "Project Name 1"}}) + groupPropertiesEquality := reflect.DeepEqual(reqBody.GroupProperties, map[string]Properties{"company": {"name": "Project Name 1"}}) if !groupPropertiesEquality { t.Errorf("Expected groupProperties to be map[company:map[name:Project Name 1]], got %s", reqBody.GroupProperties) } diff --git a/featureflags.go b/featureflags.go index 87c04a8..18d3bfb 100644 --- a/featureflags.go +++ b/featureflags.go @@ -311,7 +311,7 @@ func (poller *FeatureFlagsPoller) computeFlagLocally( groupName, exists := poller.groups[fmt.Sprintf("%d", *flag.Filters.AggregationGroupTypeIndex)] if !exists { - errMessage := "Flag has unknown group type index" + errMessage := "flag has unknown group type index" return nil, errors.New(errMessage) } @@ -467,7 +467,7 @@ func matchCohort(property FlagProperty, properties Properties, cohorts map[strin cohortId := fmt.Sprint(property.Value) propertyGroup, ok := cohorts[cohortId] if !ok { - return false, fmt.Errorf("Can't match cohort: cohort %s not found", cohortId) + return false, fmt.Errorf("can't match cohort: cohort %s not found", cohortId) } return matchPropertyGroup(propertyGroup, properties, cohorts) @@ -578,7 +578,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { return false, &InconclusiveMatchError{"Can't match properties with operator is_not_set"} } - override_value, _ := properties[key] + override_value := properties[key] if operator == "exact" { switch t := value.(type) { @@ -637,7 +637,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { valueString = strconv.Itoa(valueInt) r, err = regexp.Compile(valueString) } else { - errMessage := "Regex expression not allowed" + errMessage := "regex expression not allowed" return false, errors.New(errMessage) } @@ -653,7 +653,7 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { valueString = strconv.Itoa(valueInt) match = r.MatchString(valueString) } else { - errMessage := "Value type not supported" + errMessage := "value type not supported" return false, errors.New(errMessage) } @@ -707,12 +707,12 @@ func matchProperty(property FlagProperty, properties Properties) (bool, error) { func validateOrderable(firstValue interface{}, secondValue interface{}) (float64, float64, error) { convertedFirstValue, err := interfaceToFloat(firstValue) if err != nil { - errMessage := "Value 1 is not orderable" + errMessage := "value 1 is not orderable" return 0, 0, errors.New(errMessage) } convertedSecondValue, err := interfaceToFloat(secondValue) if err != nil { - errMessage := "Value 2 is not orderable" + errMessage := "value 2 is not orderable" return 0, 0, errors.New(errMessage) } @@ -809,7 +809,7 @@ func (poller *FeatureFlagsPoller) GetFeatureFlags() ([]FeatureFlag, error) { _, closed := <-poller.loaded if closed && poller.featureFlags == nil { // There was an error with initial flag fetching - return nil, fmt.Errorf("Flags were not successfully fetched yet") + return nil, fmt.Errorf("flags were not successfully fetched yet") } return poller.featureFlags, nil diff --git a/posthog.go b/posthog.go index 8af24ea..5d64de6 100644 --- a/posthog.go +++ b/posthog.go @@ -8,6 +8,7 @@ import ( "io" "io/ioutil" "net/http" + "net/url" "sync" "time" ) @@ -44,14 +45,19 @@ type Client interface { // if the given flag is on or off for the user GetFeatureFlag(FeatureFlagPayload) (interface{}, error) // + // Get all flags - returns all flags for a user + GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error) + // // Method forces a reload of feature flags + // NB: This is only available when using a PersonalApiKey ReloadFeatureFlags() error // // Get feature flags - for testing only + // NB: This is only available when using a PersonalApiKey GetFeatureFlags() ([]FeatureFlag, error) // - // Get all flags - returns all flags for a user - GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error) + // Get the last captured event + GetLastCapturedEvent() *Capture } type client struct { @@ -79,6 +85,11 @@ type client struct { featureFlagsPoller *FeatureFlagsPoller distinctIdsFeatureFlagsReported *SizeLimitedMap + + // Last captured event + lastCapturedEvent *Capture + // Mutex to protect last captured event + lastEventMutex sync.RWMutex } // Instantiate a new client that uses the write key passed as first argument to @@ -216,6 +227,7 @@ func (c *client) Enqueue(msg Message) (err error) { } m.Properties["$active_feature_flags"] = featureKeys } + c.setLastCapturedEvent(m) msg = m default: @@ -238,17 +250,28 @@ func (c *client) Enqueue(msg Message) (err error) { return } +func (c *client) setLastCapturedEvent(event Capture) { + c.lastEventMutex.Lock() + defer c.lastEventMutex.Unlock() + c.lastCapturedEvent = &event +} + +func (c *client) GetLastCapturedEvent() *Capture { + c.lastEventMutex.RLock() + defer c.lastEventMutex.RUnlock() + if c.lastCapturedEvent == nil { + return nil + } + // Return a copy to avoid data races + eventCopy := *c.lastCapturedEvent + return &eventCopy +} + func (c *client) IsFeatureEnabled(flagConfig FeatureFlagPayload) (interface{}, error) { if err := flagConfig.validate(); err != nil { return false, err } - if c.featureFlagsPoller == nil { - errorMessage := "specifying a PersonalApiKey is required for using feature flags" - c.Errorf(errorMessage) - return false, errors.New(errorMessage) - } - result, err := c.GetFeatureFlag(flagConfig) if err != nil { return nil, err @@ -278,12 +301,19 @@ func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, err return false, err } - if c.featureFlagsPoller == nil { - errorMessage := "specifying a PersonalApiKey is required for using feature flags" - c.Errorf(errorMessage) - return "false", errors.New(errorMessage) + var flagValue interface{} + var err error + + if c.featureFlagsPoller != nil { + // get feature flag from the poller, which uses the personal api key + // this is only available when using a PersonalApiKey + flagValue, err = c.featureFlagsPoller.GetFeatureFlag(flagConfig) + } else { + // if there's no poller, get the feature flag from the decide endpoint + c.debugf("getting feature flag from decide endpoint") + flagValue, err = c.getFeatureFlagFromDecide(flagConfig.Key, flagConfig.DistinctId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties) } - flagValue, err := c.featureFlagsPoller.GetFeatureFlag(flagConfig) + if *flagConfig.SendFeatureFlagEvents && !c.distinctIdsFeatureFlagsReported.contains(flagConfig.DistinctId, flagConfig.Key) { c.Enqueue(Capture{ DistinctId: flagConfig.DistinctId, @@ -296,6 +326,7 @@ func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, err }) c.distinctIdsFeatureFlagsReported.add(flagConfig.DistinctId, flagConfig.Key) } + return flagValue, err } @@ -314,12 +345,20 @@ func (c *client) GetAllFlags(flagConfig FeatureFlagPayloadNoKey) (map[string]int return nil, err } - if c.featureFlagsPoller == nil { - errorMessage := "specifying a PersonalApiKey is required for using feature flags" - c.Errorf(errorMessage) - return nil, errors.New(errorMessage) + var flagsValue map[string]interface{} + var err error + + if c.featureFlagsPoller != nil { + // get feature flags from the poller, which uses the personal api key + // this is only available when using a PersonalApiKey + flagsValue, err = c.featureFlagsPoller.GetAllFlags(flagConfig) + } else { + // if there's no poller, get the feature flags from the decide endpoint + c.debugf("getting all feature flags from decide endpoint") + flagsValue, err = c.getAllFeatureFlagsFromDecide(flagConfig.DistinctId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties) } - return c.featureFlagsPoller.GetAllFlags(flagConfig) + + return flagsValue, err } // Close and flush metrics. @@ -336,7 +375,7 @@ func (c *client) Close() (err error) { return } -// Asychronously send a batched requests. +// Asynchronously send a batched requests. func (c *client) sendAsync(msgs []message, wg *sync.WaitGroup, ex *executor) { wg.Add(1) @@ -558,3 +597,77 @@ func (c *client) getFeatureVariants(distinctId string, groups Groups, personProp } return featureVariants, nil } + +func (c *client) makeDecideRequest(distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (*DecideResponse, error) { + requestData := DecideRequestData{ + ApiKey: c.key, + DistinctId: distinctId, + Groups: groups, + PersonProperties: personProperties, + GroupProperties: groupProperties, + } + + requestDataBytes, err := json.Marshal(requestData) + if err != nil { + return nil, fmt.Errorf("unable to marshal decide endpoint request data: %v", err) + } + + decideEndpoint := "decide/?v=2" + url, err := url.Parse(c.Endpoint + "/" + decideEndpoint) + if err != nil { + return nil, fmt.Errorf("creating url: %v", err) + } + + req, err := http.NewRequest("POST", url.String(), bytes.NewReader(requestDataBytes)) + if err != nil { + return nil, fmt.Errorf("creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "posthog-go (version: "+Version+")") + + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code from /decide/: %d", res.StatusCode) + } + + resBody, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response from /decide/: %v", err) + } + + var decideResponse DecideResponse + err = json.Unmarshal(resBody, &decideResponse) + if err != nil { + return nil, fmt.Errorf("error parsing response from /decide/: %v", err) + } + + return &decideResponse, nil +} + +func (c *client) getFeatureFlagFromDecide(key string, distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (interface{}, error) { + decideResponse, err := c.makeDecideRequest(distinctId, groups, personProperties, groupProperties) + if err != nil { + return nil, err + } + + if value, ok := decideResponse.FeatureFlags[key]; ok { + return value, nil + } + + return false, nil +} + +func (c *client) getAllFeatureFlagsFromDecide(distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (map[string]interface{}, error) { + decideResponse, err := c.makeDecideRequest(distinctId, groups, personProperties, groupProperties) + if err != nil { + return nil, err + } + + return decideResponse.FeatureFlags, nil +} diff --git a/posthog_test.go b/posthog_test.go index f0bf009..b8bdcd0 100644 --- a/posthog_test.go +++ b/posthog_test.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -705,7 +706,7 @@ func TestClientMaxConcurrentRequests(t *testing.T) { func(m APIMessage, e error) { errchan <- e }, }, Transport: testTransportDelayed, - // Only one concurreny request can be submitted, because the transport + // Only one concurrency request can be submitted, because the transport // introduces a short delay one of the uploads should fail. BatchSize: 1, maxConcurrentRequests: 1, @@ -741,21 +742,9 @@ func TestFeatureFlagsWithNoPersonalApiKey(t *testing.T) { }) defer client.Close() - receivedErrors := [4]error{} + receivedErrors := [2]error{} receivedErrors[0] = client.ReloadFeatureFlags() - _, receivedErrors[1] = client.IsFeatureEnabled( - FeatureFlagPayload{ - Key: "some key", - DistinctId: "some id", - }, - ) - _, receivedErrors[2] = client.GetFeatureFlag( - FeatureFlagPayload{ - Key: "some key", - DistinctId: "some id", - }, - ) - _, receivedErrors[3] = client.GetFeatureFlags() + _, receivedErrors[1] = client.GetFeatureFlags() for _, receivedError := range receivedErrors { if receivedError == nil || receivedError.Error() != "specifying a PersonalApiKey is required for using feature flags" { @@ -766,6 +755,442 @@ func TestFeatureFlagsWithNoPersonalApiKey(t *testing.T) { } +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 == "/decide/" { + 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 TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/decide") { + w.Write([]byte(fixture("test-decide-v2.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 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) + } + + lastEvent := client.GetLastCapturedEvent() + 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 /decide + 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 /decide/", + }, + { + name: "Non-200 status code", + flagConfig: FeatureFlagPayload{ + Key: "test-flag", + DistinctId: "user123", + }, + mockResponse: ``, + expectedError: "unexpected status code from /decide/: 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 != "/decide/" { + t.Errorf("Expected POST /decide/, 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 (version: ") { + t.Errorf("Unexpected User-Agent: %s", r.Header.Get("User-Agent")) + } + + // Check request body + body, _ := ioutil.ReadAll(r.Body) + var requestData DecideRequestData + 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 /decide/: 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 /decide/", + }, + { + name: "Non-200 status code", + flagConfig: FeatureFlagPayloadNoKey{ + DistinctId: "user101", + }, + mockResponse: ``, + expectedError: "unexpected status code from /decide/: 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 != "/decide/" { + t.Errorf("Expected POST /decide/, 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 (version: ") { + t.Errorf("Unexpected User-Agent: %s", r.Header.Get("User-Agent")) + } + + // Check request body + body, _ := ioutil.ReadAll(r.Body) + var requestData DecideRequestData + 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 /decide/: 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 decide 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 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"))) @@ -788,11 +1213,6 @@ func TestSimpleFlagOld(t *testing.T) { if checkErr != nil || isEnabled != true { t.Errorf("simple flag with null rollout percentage should be on for everyone") } - - // flagValue, valueError := client.GetFeatureFlag("simpleFlag", "hey", false, Groups{}, NewProperties(), map[string]Properties{}) - // if valueError != nil || flagValue != true { - // t.Errorf("simple flag with null rollout percentage should have value 'true'") - // } } func TestSimpleFlagCalculation(t *testing.T) {