diff --git a/.sampo/changesets/posthog-openfeature-provider.md b/.sampo/changesets/posthog-openfeature-provider.md new file mode 100644 index 0000000..6988f49 --- /dev/null +++ b/.sampo/changesets/posthog-openfeature-provider.md @@ -0,0 +1,5 @@ +--- +hex/posthog: minor +--- + +Add `PostHog.OpenFeature.Provider`, an OpenFeature provider for the Elixir OpenFeature SDK. Add `{:open_feature, "~> 0.1"}` to your dependencies to use it. diff --git a/lib/posthog/open_feature.ex b/lib/posthog/open_feature.ex new file mode 100644 index 0000000..26c2a8a --- /dev/null +++ b/lib/posthog/open_feature.ex @@ -0,0 +1,303 @@ +if Code.ensure_loaded?(OpenFeature.Provider) do + defmodule PostHog.OpenFeature.Provider do + @moduledoc """ + [OpenFeature](https://openfeature.dev) provider backed by PostHog feature flags. + + Requires the optional `:open_feature` dependency: + + {:open_feature, "~> 0.1"} + + ## Usage + + {:ok, _provider} = OpenFeature.set_provider(%PostHog.OpenFeature.Provider{}) + client = OpenFeature.get_client() + + OpenFeature.Client.get_boolean_value(client, "new-dashboard", false, + context: %{ + "targeting_key" => "user-123", + "plan" => "enterprise", + "groups" => %{"company" => "acme"}, + "group_properties" => %{"company" => %{"size" => 250}} + } + ) + + ## Options + + - `:supervisor_name` - PostHog instance to evaluate flags with. Defaults to + `PostHog`. + - `:default_distinct_id` - distinct ID used when the evaluation context has + no `targeting_key`. When unset, such evaluations fail with + `:targeting_key_missing`. + - `:send_feature_flag_events` - whether evaluations fire + `$feature_flag_called` events. Defaults to `true`. + + ## Evaluation context + + Keys can be strings or atoms. + + - `targeting_key` - used as the PostHog `distinct_id`. + - `groups` - map of group type to group key. + - `group_properties` - map of group type to group properties. + - Every other key is sent as a person property. + + ## Resolution + + - Boolean flags resolve to whether the flag is enabled. + - String flags resolve to the variant key. + - Number flags resolve to the variant key parsed as a number, including + unsigned hexadecimal integers such as `0x10`. + - Map flags resolve to the flag's JSON object or array payload. The upstream + OpenFeature SDK requires a map default (`%{}`), even for array payloads; + `get_map_value/4` can return a list when the payload is an array. + + For string, number, and map reads, a disabled flag returns the default value, + unless it still carries a variant (or object/array payload, for maps). That + value is then returned, as in the Node and Python providers. Boolean reads + return the flag's enabled state, including `false` regardless of the default. + Reading an enabled flag whose value doesn't fit the requested type returns + the default value with `error_code: :type_mismatch`. Unknown flags return + `:flag_not_found`. + + Resolution details include `flag_metadata["posthog_reason"]` when PostHog + supplies an evaluation reason. Off results use `:disabled` when the server + explicitly reports the `flag_disabled` reason code, otherwise `:default`. + + The OpenFeature Elixir SDK has no error tuple for `:type_mismatch` or + `:targeting_key_missing`, so these are returned as resolution details with + `reason: :error` and run `after` hooks rather than `error` hooks. + """ + + @behaviour OpenFeature.Provider + + alias OpenFeature.ResolutionDetails + alias PostHog.FeatureFlags + alias PostHog.FeatureFlags.{Evaluations, Result} + + @typedoc "PostHog OpenFeature provider." + @type t :: %__MODULE__{ + name: String.t(), + domain: String.t() | nil, + state: atom(), + hooks: list(), + supervisor_name: PostHog.supervisor_name(), + default_distinct_id: PostHog.distinct_id() | nil, + send_feature_flag_events: boolean() + } + + defstruct name: "PostHogProvider", + domain: nil, + state: :not_ready, + hooks: [], + supervisor_name: PostHog, + default_distinct_id: nil, + send_feature_flag_events: true + + @reserved_keys ["targeting_key", "groups", "group_properties"] + + @impl true + def initialize(%__MODULE__{} = provider, domain, _context) do + {:ok, %{provider | domain: domain, state: :ready}} + end + + @impl true + def shutdown(_provider), do: :ok + + @impl true + def resolve_boolean_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + {:ok, details(result, result.enabled)} + end + end + + @impl true + def resolve_string_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + case result do + %Result{variant: nil, enabled: false} -> + {:ok, default_details(result, default)} + + %Result{variant: nil} -> + {:ok, type_mismatch(result, default, "Flag '#{key}' has no string variant.")} + + %Result{variant: variant} -> + {:ok, details(result, variant)} + end + end + end + + @impl true + def resolve_number_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + case result do + %Result{variant: nil, enabled: false} -> + {:ok, default_details(result, default)} + + %Result{variant: nil} -> + {:ok, type_mismatch(result, default, "Flag '#{key}' has no numeric variant.")} + + %Result{variant: variant} -> + {:ok, number_details(result, key, variant, default)} + end + end + end + + # open-feature/elixir-sdk 0.1.3 guards get_map_value/get_map_details defaults + # with is_map/1 before calling the provider, but does not restrict returned + # values. Array payloads work with a map default; list defaults need an upstream fix. + @impl true + def resolve_map_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + case result do + %Result{payload: payload} when is_map(payload) or is_list(payload) -> + {:ok, details(result, payload)} + + %Result{enabled: false} -> + {:ok, default_details(result, default)} + + %Result{} -> + {:ok, type_mismatch(result, default, "Flag '#{key}' has no object/JSON payload.")} + end + end + end + + defp evaluate(%__MODULE__{} = provider, key, default, context) do + case resolve_distinct_id(provider, context) do + {:ok, distinct_id} -> + body = flags_body(distinct_id, key, context) + + case FeatureFlags.evaluate_flags(provider.supervisor_name, body) do + {:ok, snapshot} -> fetch_result(provider, snapshot, key) + {:error, reason} -> {:error, :unexpected_error, to_exception(reason)} + end + + :error -> + {:ok, + error_details( + default, + :targeting_key_missing, + "No targeting_key in evaluation context and no default_distinct_id configured." + )} + end + end + + defp fetch_result(provider, %Evaluations{flags: flags} = snapshot, key) do + if provider.send_feature_flag_events, do: Evaluations.get_flag(snapshot, key) + + case Map.fetch(flags, key) do + {:ok, result} -> {:ok, result} + :error -> {:error, :flag_not_found} + end + end + + defp resolve_distinct_id(provider, context) do + case get_key(context, :targeting_key) do + targeting_key when targeting_key not in [nil, ""] -> {:ok, targeting_key} + _ when not is_nil(provider.default_distinct_id) -> {:ok, provider.default_distinct_id} + _ -> :error + end + end + + defp flags_body(distinct_id, key, context) do + person_properties = + context + |> Enum.map(fn {k, v} -> {to_string(k), v} end) + |> Enum.reject(fn {k, _v} -> k in @reserved_keys end) + |> Map.new() + + %{distinct_id: distinct_id, flag_keys: [key]} + |> put_non_empty(:person_properties, person_properties) + |> put_non_empty(:groups, get_key(context, :groups)) + |> put_non_empty(:group_properties, get_key(context, :group_properties)) + end + + defp get_key(context, key) do + Map.get(context, Atom.to_string(key), Map.get(context, key)) + end + + defp put_non_empty(body, key, value) when is_map(value) and map_size(value) > 0, + do: Map.put(body, key, value) + + defp put_non_empty(body, _key, _value), do: body + + defp number_details(result, key, variant, default) do + case parse_number(variant) do + {:ok, number} -> + details(result, number) + + :error -> + type_mismatch(result, default, "Flag '#{key}' variant '#{variant}' is not a number.") + end + end + + defp parse_number(variant) do + trimmed = String.trim(variant) + + cond do + trimmed =~ ~r/\A0[xX][0-9a-fA-F]+\z/ -> + <<_prefix::binary-size(2), digits::binary>> = trimmed + {:ok, String.to_integer(digits, 16)} + + trimmed =~ ~r/\d/ -> + case Integer.parse(trimmed) do + {integer, ""} -> {:ok, integer} + _ -> parse_float(trimmed) + end + + true -> + :error + end + end + + # Float.parse/1 needs digits on both sides of the decimal point, unlike + # JavaScript's Number() and Python's float(), so `.5` and `1.` are padded. + defp parse_float(string) do + padded = + string + |> String.replace(~r/^([+-]?)\./, "\\g{1}0.") + |> String.replace(~r/\.$/, ".0") + + case Float.parse(padded) do + {float, ""} -> {:ok, float} + _ -> :error + end + end + + defp details(%Result{} = result, value) do + %ResolutionDetails{ + value: value, + variant: result.variant, + reason: resolution_reason(result), + flag_metadata: reason_metadata(result.reason) + } + end + + defp resolution_reason(%Result{enabled: true}), do: :targeting_match + defp resolution_reason(%Result{reason: %{"code" => "flag_disabled"}}), do: :disabled + defp resolution_reason(_result), do: :default + + defp reason_metadata(%{"description" => description}) when is_binary(description), + do: %{"posthog_reason" => description} + + defp reason_metadata(%{"code" => code}) when is_binary(code), + do: %{"posthog_reason" => code} + + defp reason_metadata(reason) when is_binary(reason), do: %{"posthog_reason" => reason} + defp reason_metadata(_reason), do: %{} + + defp default_details(result, default), do: %{details(result, default) | variant: nil} + + defp type_mismatch(result, default, message) do + %{ + error_details(default, :type_mismatch, message) + | flag_metadata: reason_metadata(result.reason) + } + end + + defp error_details(default, code, message) do + %ResolutionDetails{value: default, reason: :error, error_code: code, error_message: message} + end + + defp to_exception(%{__exception__: true} = exception), do: exception + defp to_exception(reason), do: %PostHog.Error{message: inspect(reason)} + end +end diff --git a/mix.exs b/mix.exs index a6d346e..46b1dc5 100644 --- a/mix.exs +++ b/mix.exs @@ -69,6 +69,7 @@ defmodule PostHog.MixProject do {:logger_json, "~> 7.0"}, {:nimble_ownership, "~> 1.0"}, {:uuid_v7, "~> 0.6"}, + {:open_feature, "~> 0.1", optional: true}, # Development tools {:ex_doc, "~> 0.37", only: :dev, runtime: false}, {:logger_handler_kit, "~> 0.4", only: :test}, diff --git a/mix.lock b/mix.lock index 1723e27..5c53b87 100644 --- a/mix.lock +++ b/mix.lock @@ -23,6 +23,7 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "open_feature": {:hex, :open_feature, "0.1.3", "b03ea38818010dd04c3e690059a8baf14a797c9322af06eb40ac3f81c733474e", [:mix], [], "hexpm", "2dafd79e94384328469c655ef2ddc66647c2531a53b57634e012f952b6afcaf9"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, diff --git a/public_api.snapshot b/public_api.snapshot index 4243197..aff3f9e 100644 --- a/public_api.snapshot +++ b/public_api.snapshot @@ -210,6 +210,13 @@ PostHog.LLMAnalytics macros: (none) +PostHog.OpenFeature.Provider + module_doc: documented + functions: + (none) + macros: + (none) + PostHog.Supervisor module_doc: documented functions: diff --git a/test/posthog/open_feature_test.exs b/test/posthog/open_feature_test.exs new file mode 100644 index 0000000..0e79809 --- /dev/null +++ b/test/posthog/open_feature_test.exs @@ -0,0 +1,515 @@ +defmodule PostHog.OpenFeature.ProviderTest do + use PostHog.Case, async: false, group: PostHog + + @moduletag config: [supervisor_name: PostHog] + + import Mox + + alias OpenFeature.ResolutionDetails + alias PostHog.API + alias PostHog.OpenFeature.Provider + + setup :setup_supervisor + setup :verify_on_exit! + + @context %{"targeting_key" => "user-1"} + + defp flag(attrs) do + Map.merge(%{"enabled" => true, "variant" => nil, "metadata" => %{"payload" => nil}}, attrs) + end + + defp expect_flags(flags) do + expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> + {:ok, %{status: 200, body: %{"flags" => flags}}} + end) + end + + defp expect_flag(key, attrs), do: expect_flags(%{key => flag(Map.put(attrs, "key", key))}) + + defp provider(opts \\ []) do + {:ok, provider} = Provider.initialize(struct(Provider, opts), "default", %{}) + provider + end + + describe "initialize/3" do + test "marks the provider ready and keeps the domain" do + assert {:ok, %Provider{state: :ready, domain: "my-domain", name: "PostHogProvider"}} = + Provider.initialize(%Provider{}, "my-domain", %{}) + end + end + + describe "resolve_boolean_value/4" do + test "resolves an enabled flag" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, %ResolutionDetails{value: true, reason: :targeting_match, variant: nil}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + + test "resolves a disabled flag" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: false, reason: :default}} = + Provider.resolve_boolean_value(provider(), "flag", true, @context) + end + + test "includes the variant for multivariate flags" do + expect_flag("flag", %{"variant" => "test"}) + + assert {:ok, %ResolutionDetails{value: true, variant: "test"}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + end + + describe "resolve_string_value/4" do + test "resolves the variant" do + expect_flag("flag", %{"variant" => "test"}) + + assert {:ok, %ResolutionDetails{value: "test", variant: "test", reason: :targeting_match}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + + test "returns the default for a disabled flag without a variant" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: "control", reason: :default, error_code: nil}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + + test "returns the variant for a disabled flag that still has one" do + expect_flag("flag", %{"enabled" => false, "variant" => "test"}) + + assert {:ok, %ResolutionDetails{value: "test", variant: "test", reason: :default}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + + test "returns type_mismatch for an enabled flag without a variant" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, + %ResolutionDetails{value: "control", reason: :error, error_code: :type_mismatch}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + end + + describe "resolve_number_value/4" do + for {variant, expected} <- [ + {"42", 42}, + {"3.5", 3.5}, + {" 7 ", 7}, + {"-1", -1}, + {".5", 0.5}, + {"-.5", -0.5}, + {"1.", 1.0}, + {"0x10", 16}, + {"0Xff", 255}, + {" 0xA0 ", 160}, + {"0x0", 0} + ] do + test "parses variant #{inspect(variant)}" do + expect_flag("flag", %{"variant" => unquote(variant)}) + + assert {:ok, %ResolutionDetails{value: value, reason: :targeting_match}} = + Provider.resolve_number_value(provider(), "flag", 0, @context) + + assert value === unquote(expected) + end + end + + for variant <- [ + "abc", + "", + " ", + "12abc", + ".", + "-.", + "0x", + "0xgg", + "0x1.5", + "0x_10", + "-0x10", + "+0x10" + ] do + test "returns type_mismatch for variant #{inspect(variant)}" do + expect_flag("flag", %{"variant" => unquote(variant)}) + + assert {:ok, %ResolutionDetails{value: 0, reason: :error, error_code: :type_mismatch}} = + Provider.resolve_number_value(provider(), "flag", 0, @context) + end + end + + test "returns the default for a disabled flag without a variant" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: 5, reason: :default}} = + Provider.resolve_number_value(provider(), "flag", 5, @context) + end + + test "returns the parsed variant for a disabled flag that still has one" do + expect_flag("flag", %{"enabled" => false, "variant" => "42"}) + + assert {:ok, %ResolutionDetails{value: 42, variant: "42", reason: :default}} = + Provider.resolve_number_value(provider(), "flag", 5, @context) + end + + test "returns type_mismatch for an enabled flag without a variant" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, %ResolutionDetails{value: 5, error_code: :type_mismatch}} = + Provider.resolve_number_value(provider(), "flag", 5, @context) + end + end + + describe "resolve_map_value/4" do + test "resolves the payload" do + expect_flag("flag", %{"variant" => "test", "metadata" => %{"payload" => ~s({"a": 1})}}) + + assert {:ok, %ResolutionDetails{value: %{"a" => 1}, variant: "test"}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + + test "returns the default for a disabled flag without a payload" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: %{"d" => 1}, reason: :default}} = + Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) + end + + test "returns the payload for a disabled flag that still has one" do + expect_flag("flag", %{"enabled" => false, "metadata" => %{"payload" => ~s({"a": 1})}}) + + assert {:ok, %ResolutionDetails{value: %{"a" => 1}, reason: :default, variant: nil}} = + Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) + end + + test "returns type_mismatch for an enabled flag without a payload" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, %ResolutionDetails{value: %{}, error_code: :type_mismatch}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + + for payload <- [[], [1, 2], [%{"nested" => [true, nil]}]] do + test "resolves array payload #{inspect(payload)}" do + payload = unquote(Macro.escape(payload)) + expect_flag("flag", %{"metadata" => %{"payload" => Jason.encode!(payload)}}) + + assert {:ok, %ResolutionDetails{value: ^payload, reason: :targeting_match}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + end + + test "preserves array payloads on disabled flags" do + expect_flag("flag", %{"enabled" => false, "metadata" => %{"payload" => "[]"}}) + + assert {:ok, %ResolutionDetails{value: [], reason: :default}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + + test "returns type_mismatch for a scalar payload" do + expect_flag("flag", %{"metadata" => %{"payload" => "123"}}) + + assert {:ok, %ResolutionDetails{value: %{}, error_code: :type_mismatch}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + end + + describe "reason metadata" do + for {resolver, default, attrs} <- [ + {:resolve_boolean_value, false, %{}}, + {:resolve_string_value, "fallback", %{"variant" => "test"}}, + {:resolve_number_value, 0, %{"variant" => "42"}}, + {:resolve_map_value, %{}, %{"metadata" => %{"payload" => "{}"}}} + ] do + test "preserves PostHog reason for #{resolver}" do + expect_flag( + "flag", + Map.put(unquote(Macro.escape(attrs)), "reason", %{ + "code" => "condition_match", + "description" => "Matched condition set 1" + }) + ) + + assert {:ok, + %ResolutionDetails{ + reason: :targeting_match, + flag_metadata: %{"posthog_reason" => "Matched condition set 1"} + }} = + apply(Provider, unquote(resolver), [ + provider(), + "flag", + unquote(Macro.escape(default)), + @context + ]) + end + + test "reports explicit disabled reason for #{resolver}" do + expect_flag("flag", %{ + "enabled" => false, + "reason" => %{ + "code" => "flag_disabled", + "description" => "Flag switched off" + } + }) + + assert {:ok, + %ResolutionDetails{ + reason: :disabled, + flag_metadata: %{"posthog_reason" => "Flag switched off"} + }} = + apply(Provider, unquote(resolver), [ + provider(), + "flag", + unquote(Macro.escape(default)), + @context + ]) + end + end + + for {label, resolver, default, attrs} <- [ + {"missing string variant", :resolve_string_value, "fallback", %{}}, + {"missing numeric variant", :resolve_number_value, 0, %{}}, + {"invalid numeric variant", :resolve_number_value, 0, %{"variant" => "abc"}}, + {"missing payload", :resolve_map_value, %{}, %{}}, + {"scalar payload", :resolve_map_value, %{}, %{"metadata" => %{"payload" => "123"}}} + ] do + test "preserves reason metadata on type mismatch for #{label}" do + expect_flag( + "flag", + Map.put(unquote(Macro.escape(attrs)), "reason", %{ + "code" => "condition_match", + "description" => "Matched condition set 1" + }) + ) + + assert {:ok, + %ResolutionDetails{ + value: unquote(Macro.escape(default)), + reason: :error, + error_code: :type_mismatch, + flag_metadata: %{"posthog_reason" => "Matched condition set 1"} + }} = + apply(Provider, unquote(resolver), [ + provider(), + "flag", + unquote(Macro.escape(default)), + @context + ]) + end + end + + test "does not infer disabled from the description" do + expect_flag("flag", %{ + "enabled" => false, + "reason" => %{ + "code" => "no_condition_match", + "description" => "User property disabled is true" + } + }) + + assert {:ok, %ResolutionDetails{reason: :default}} = + Provider.resolve_boolean_value(provider(), "flag", true, @context) + end + + test "falls back to the reason code for metadata" do + expect_flag("flag", %{"enabled" => false, "reason" => %{"code" => "flag_disabled"}}) + + assert {:ok, + %ResolutionDetails{ + reason: :disabled, + flag_metadata: %{"posthog_reason" => "flag_disabled"} + }} = + Provider.resolve_boolean_value(provider(), "flag", true, @context) + end + + test "preserves string reasons used by local evaluation" do + expect_flag("flag", %{"reason" => "Evaluated locally"}) + + assert {:ok, + %ResolutionDetails{ + reason: :targeting_match, + flag_metadata: %{"posthog_reason" => "Evaluated locally"} + }} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + + test "omits metadata when no reason is available" do + expect_flag("flag", %{}) + + assert {:ok, %ResolutionDetails{flag_metadata: %{}}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + end + + describe "errors" do + test "returns flag_not_found when the flag is not in the response" do + expect_flags(%{}) + + assert {:error, :flag_not_found} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + + test "returns unexpected_error when the request fails" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:error, :transport_error} + end) + + assert {:error, :unexpected_error, %PostHog.Error{}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + end + + describe "distinct ID" do + for context <- [%{"targeting_key" => "user-1"}, %{targeting_key: "user-1"}] do + test "uses targeting_key from #{inspect(context)}" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json].distinct_id == "user-1" + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + assert {:ok, _} = + Provider.resolve_boolean_value( + provider(), + "flag", + false, + unquote(Macro.escape(context)) + ) + end + end + + test "falls back to default_distinct_id" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json].distinct_id == "fallback" + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + assert {:ok, %ResolutionDetails{value: true}} = + Provider.resolve_boolean_value( + provider(default_distinct_id: "fallback"), + "flag", + false, + %{} + ) + end + + test "returns targeting_key_missing without targeting_key or default_distinct_id" do + expect(API.Mock, :request, 0, fn _client, _method, _url, _opts -> :unreachable end) + + assert {:ok, + %ResolutionDetails{ + value: false, + reason: :error, + error_code: :targeting_key_missing + }} = Provider.resolve_boolean_value(provider(), "flag", false, %{}) + end + end + + describe "context mapping" do + test "forwards person properties, groups, and group properties without coercion" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{ + distinct_id: "user-1", + flag_keys_to_evaluate: ["flag"], + person_properties: %{"plan" => "pro", "age" => 30, "beta" => true}, + groups: %{"company" => "acme"}, + group_properties: %{"company" => %{"size" => 250}} + } + + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + context = %{ + "targeting_key" => "user-1", + "plan" => "pro", + :age => 30, + "beta" => true, + "groups" => %{"company" => "acme"}, + :group_properties => %{"company" => %{"size" => 250}} + } + + assert {:ok, _} = Provider.resolve_boolean_value(provider(), "flag", false, context) + end + + test "omits empty person properties, groups, and group properties" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{distinct_id: "user-1", flag_keys_to_evaluate: ["flag"]} + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + context = %{"targeting_key" => "user-1", "groups" => %{}, "group_properties" => %{}} + assert {:ok, _} = Provider.resolve_boolean_value(provider(), "flag", false, context) + end + end + + describe "send_feature_flag_events" do + test "fires $feature_flag_called by default" do + expect_flag("flag", %{}) + + assert {:ok, _} = Provider.resolve_boolean_value(provider(), "flag", false, @context) + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "user-1", + properties: %{"$feature_flag": "flag", "$feature_flag_response": true} + } + ] = all_captured() + end + + test "does not fire events when disabled" do + expect_flag("flag", %{}) + + assert {:ok, _} = + Provider.resolve_boolean_value( + provider(send_feature_flag_events: false), + "flag", + false, + @context + ) + + assert all_captured() == [] + end + end + + describe "with the OpenFeature client" do + setup do + OpenFeature.clear_providers() + {:ok, _} = OpenFeature.set_provider(%Provider{}) + on_exit(&OpenFeature.clear_providers/0) + %{client: OpenFeature.get_client()} + end + + test "resolves values", %{client: client} do + expect_flag("flag", %{"variant" => "test"}) + + assert OpenFeature.Client.get_string_value(client, "flag", "control", context: @context) == + "test" + end + + test "resolves array payloads with a map default", %{client: client} do + expect_flag("flag", %{"metadata" => %{"payload" => "[1, 2]"}}) + assert OpenFeature.Client.get_map_value(client, "flag", %{}, context: @context) == [1, 2] + end + + test "surfaces disabled reason and metadata", %{client: client} do + expect_flag("flag", %{"enabled" => false, "reason" => %{"code" => "flag_disabled"}}) + + assert %{reason: :disabled, flag_metadata: %{"posthog_reason" => "flag_disabled"}} = + OpenFeature.Client.get_boolean_details(client, "flag", true, context: @context) + end + + test "surfaces type_mismatch", %{client: client} do + expect_flag("flag", %{"enabled" => true}) + + assert %{value: "control", reason: :error, error_code: :type_mismatch} = + OpenFeature.Client.get_string_details(client, "flag", "control", context: @context) + end + + test "surfaces flag_not_found", %{client: client} do + expect_flags(%{}) + + assert %{value: false, reason: :error, error_code: :flag_not_found} = + OpenFeature.Client.get_boolean_details(client, "flag", false, context: @context) + end + end +end