subflag-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +245 -0
- data/lib/generators/subflag/install_generator.rb +56 -0
- data/lib/generators/subflag/templates/initializer.rb +28 -0
- data/lib/subflag/rails/client.rb +143 -0
- data/lib/subflag/rails/configuration.rb +83 -0
- data/lib/subflag/rails/context_builder.rb +62 -0
- data/lib/subflag/rails/evaluation_result.rb +97 -0
- data/lib/subflag/rails/flag_accessor.rb +129 -0
- data/lib/subflag/rails/helpers.rb +108 -0
- data/lib/subflag/rails/railtie.rb +45 -0
- data/lib/subflag/rails/request_cache.rb +80 -0
- data/lib/subflag/rails/test_helpers.rb +149 -0
- data/lib/subflag/rails/version.rb +7 -0
- data/lib/subflag/rails.rb +96 -0
- data/lib/subflag-rails.rb +3 -0
- metadata +221 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Result of a flag evaluation with full details
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# result = Subflag.flags(user: current_user).evaluate(:max_projects, default: 3)
|
|
9
|
+
# result.value # => 100
|
|
10
|
+
# result.variant # => "premium"
|
|
11
|
+
# result.reason # => :targeting_match
|
|
12
|
+
# result.flag_key # => "max-projects"
|
|
13
|
+
# result.default? # => false
|
|
14
|
+
# result.error? # => false
|
|
15
|
+
#
|
|
16
|
+
class EvaluationResult
|
|
17
|
+
# @return [Object] The resolved flag value
|
|
18
|
+
attr_reader :value
|
|
19
|
+
|
|
20
|
+
# @return [String, nil] The variant name that was selected
|
|
21
|
+
attr_reader :variant
|
|
22
|
+
|
|
23
|
+
# @return [Symbol] The reason for this evaluation result
|
|
24
|
+
# Possible values: :default, :targeting_match, :static, :split, :error, :unknown
|
|
25
|
+
attr_reader :reason
|
|
26
|
+
|
|
27
|
+
# @return [String] The flag key that was evaluated
|
|
28
|
+
attr_reader :flag_key
|
|
29
|
+
|
|
30
|
+
# @return [Symbol, nil] Error code if evaluation failed
|
|
31
|
+
attr_reader :error_code
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] Error message if evaluation failed
|
|
34
|
+
attr_reader :error_message
|
|
35
|
+
|
|
36
|
+
def initialize(value:, variant: nil, reason: :unknown, flag_key:, error_code: nil, error_message: nil)
|
|
37
|
+
@value = value
|
|
38
|
+
@variant = variant
|
|
39
|
+
@reason = reason
|
|
40
|
+
@flag_key = flag_key
|
|
41
|
+
@error_code = error_code
|
|
42
|
+
@error_message = error_message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if the default value was returned
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def default?
|
|
49
|
+
reason == :default
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check if there was an error during evaluation
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def error?
|
|
56
|
+
reason == :error || !error_code.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if the value came from targeting rules
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def targeted?
|
|
63
|
+
reason == :targeting_match
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Convert to hash representation
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash]
|
|
69
|
+
def to_h
|
|
70
|
+
{
|
|
71
|
+
value: value,
|
|
72
|
+
variant: variant,
|
|
73
|
+
reason: reason,
|
|
74
|
+
flag_key: flag_key,
|
|
75
|
+
error_code: error_code,
|
|
76
|
+
error_message: error_message
|
|
77
|
+
}.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build from OpenFeature evaluation details
|
|
81
|
+
#
|
|
82
|
+
# @param details [Hash] OpenFeature evaluation details hash
|
|
83
|
+
# @param flag_key [String] The flag key
|
|
84
|
+
# @return [EvaluationResult]
|
|
85
|
+
def self.from_openfeature(details, flag_key:)
|
|
86
|
+
new(
|
|
87
|
+
value: details[:value],
|
|
88
|
+
variant: details[:variant],
|
|
89
|
+
reason: details[:reason] || :unknown,
|
|
90
|
+
flag_key: flag_key,
|
|
91
|
+
error_code: details[:error_code],
|
|
92
|
+
error_message: details[:error_message]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Dynamic flag accessor using method_missing
|
|
6
|
+
#
|
|
7
|
+
# Provides a clean DSL for accessing flags:
|
|
8
|
+
#
|
|
9
|
+
# @example Boolean flags (? suffix) - default is optional (false)
|
|
10
|
+
# Subflag.flags.new_checkout?
|
|
11
|
+
# Subflag.flags.new_checkout?(default: true)
|
|
12
|
+
#
|
|
13
|
+
# @example Value flags - default is REQUIRED
|
|
14
|
+
# Subflag.flags.homepage_headline(default: "Welcome")
|
|
15
|
+
# Subflag.flags.max_projects(default: 3)
|
|
16
|
+
# Subflag.flags.tax_rate(default: 0.08)
|
|
17
|
+
# Subflag.flags.feature_limits(default: {})
|
|
18
|
+
#
|
|
19
|
+
# @example With user context
|
|
20
|
+
# Subflag.flags(user: current_user).max_projects(default: 3)
|
|
21
|
+
#
|
|
22
|
+
# @example Bracket access (exact flag names, default required)
|
|
23
|
+
# Subflag.flags["my-exact-flag", default: "value"]
|
|
24
|
+
#
|
|
25
|
+
# @example Full evaluation details
|
|
26
|
+
# result = Subflag.flags.evaluate(:max_projects, default: 3)
|
|
27
|
+
# result.value # => 100
|
|
28
|
+
# result.reason # => :targeting_match
|
|
29
|
+
#
|
|
30
|
+
# Flag names are automatically converted:
|
|
31
|
+
# - Underscores become dashes: `new_checkout` → `new-checkout`
|
|
32
|
+
# - Trailing ? is removed for booleans: `enabled?` → `enabled`
|
|
33
|
+
#
|
|
34
|
+
class FlagAccessor
|
|
35
|
+
def initialize(user: nil, context: nil)
|
|
36
|
+
@user = user
|
|
37
|
+
@context = context
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Bracket access for exact flag names (no conversion)
|
|
41
|
+
#
|
|
42
|
+
# @param flag_name [String, Symbol] The exact flag name
|
|
43
|
+
# @param default [Object] Default value (required)
|
|
44
|
+
# @return [Object] The flag value
|
|
45
|
+
# @raise [ArgumentError] If default is not provided
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# Subflag.flags["my-exact-flag", default: "value"]
|
|
49
|
+
#
|
|
50
|
+
def [](flag_name, default:)
|
|
51
|
+
flag_key = flag_name.to_s
|
|
52
|
+
client.value(flag_key, user: @user, context: @context, default: default)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get full evaluation details for a flag
|
|
56
|
+
#
|
|
57
|
+
# @param flag_name [String, Symbol] The flag name
|
|
58
|
+
# @param default [Object] Default value (required)
|
|
59
|
+
# @return [EvaluationResult] Full evaluation result
|
|
60
|
+
# @raise [ArgumentError] If default is not provided
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# result = Subflag.flags.evaluate(:max_projects, default: 3)
|
|
64
|
+
# result.value # => 100
|
|
65
|
+
# result.variant # => "premium"
|
|
66
|
+
# result.reason # => :targeting_match
|
|
67
|
+
#
|
|
68
|
+
def evaluate(flag_name, default:)
|
|
69
|
+
flag_key = normalize_flag_name(flag_name)
|
|
70
|
+
client.evaluate(flag_key, user: @user, context: @context, default: default)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Handle dynamic flag access
|
|
74
|
+
#
|
|
75
|
+
# Method names ending in ? are treated as boolean flags (default: false).
|
|
76
|
+
# All other methods require a default: keyword argument.
|
|
77
|
+
#
|
|
78
|
+
# @example Boolean (default optional)
|
|
79
|
+
# flags.new_checkout? # default: false
|
|
80
|
+
# flags.new_checkout?(default: true)
|
|
81
|
+
#
|
|
82
|
+
# @example Value (default required)
|
|
83
|
+
# flags.max_projects(default: 3)
|
|
84
|
+
# flags.headline(default: "Hi")
|
|
85
|
+
#
|
|
86
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
87
|
+
flag_key = normalize_flag_name(method_name)
|
|
88
|
+
|
|
89
|
+
if method_name.to_s.end_with?("?")
|
|
90
|
+
# Boolean flag - default is optional (false)
|
|
91
|
+
default = kwargs.fetch(:default, false)
|
|
92
|
+
client.enabled?(flag_key, user: @user, context: @context, default: default)
|
|
93
|
+
else
|
|
94
|
+
# Value flag - default is required
|
|
95
|
+
unless kwargs.key?(:default)
|
|
96
|
+
raise ArgumentError, "default is required: Subflag.flags.#{method_name}(default: <value>)"
|
|
97
|
+
end
|
|
98
|
+
client.value(flag_key, user: @user, context: @context, default: kwargs[:default])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def client
|
|
109
|
+
Subflag::Rails.client
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Convert Ruby method name to flag key
|
|
113
|
+
#
|
|
114
|
+
# - Removes trailing ? for boolean methods
|
|
115
|
+
# - Converts underscores to dashes
|
|
116
|
+
#
|
|
117
|
+
# @param method_name [Symbol, String] The method/flag name
|
|
118
|
+
# @return [String] The normalized flag key
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# normalize_flag_name(:new_checkout?) # => "new-checkout"
|
|
122
|
+
# normalize_flag_name(:max_projects) # => "max-projects"
|
|
123
|
+
#
|
|
124
|
+
def normalize_flag_name(method_name)
|
|
125
|
+
method_name.to_s.chomp("?").tr("_", "-")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Helpers for using Subflag in controllers and views
|
|
6
|
+
#
|
|
7
|
+
# All helpers automatically use `current_user` for targeting if available.
|
|
8
|
+
# Override with `user: nil` or `user: other_user` when needed.
|
|
9
|
+
#
|
|
10
|
+
# @example Controller
|
|
11
|
+
# class ProjectsController < ApplicationController
|
|
12
|
+
# def index
|
|
13
|
+
# if subflag_enabled?(:new_dashboard)
|
|
14
|
+
# # show new dashboard
|
|
15
|
+
# end
|
|
16
|
+
# @max_projects = subflag_value(:max_projects, default: 3)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example View
|
|
21
|
+
# <% if subflag_enabled?(:new_checkout) %>
|
|
22
|
+
# <%= render "new_checkout" %>
|
|
23
|
+
# <% end %>
|
|
24
|
+
# <h1><%= subflag_value(:headline, default: "Welcome") %></h1>
|
|
25
|
+
#
|
|
26
|
+
# @example Flag accessor for multiple checks
|
|
27
|
+
# flags = subflag_for
|
|
28
|
+
# if flags.beta_feature?
|
|
29
|
+
# max = flags.max_projects(default: 3)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
module Helpers
|
|
33
|
+
# Check if a boolean flag is enabled
|
|
34
|
+
#
|
|
35
|
+
# Automatically scoped to current_user if available.
|
|
36
|
+
#
|
|
37
|
+
# @param flag_name [Symbol, String] The flag name (underscores → dashes)
|
|
38
|
+
# @param user [Object, nil, :auto] User for targeting (default: current_user)
|
|
39
|
+
# @param context [Hash, nil] Additional context attributes
|
|
40
|
+
# @param default [Boolean] Default value (optional, defaults to false)
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
#
|
|
43
|
+
# @example
|
|
44
|
+
# <% if subflag_enabled?(:new_checkout) %>
|
|
45
|
+
# <% if subflag_enabled?(:admin_feature, user: nil) %> <!-- no user context -->
|
|
46
|
+
#
|
|
47
|
+
def subflag_enabled?(flag_name, user: :auto, context: nil, default: false)
|
|
48
|
+
resolved = resolve_user(user)
|
|
49
|
+
Subflag.flags(user: resolved, context: context).public_send(:"#{flag_name}?", default: default)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get a flag value (default required)
|
|
53
|
+
#
|
|
54
|
+
# Automatically scoped to current_user if available.
|
|
55
|
+
#
|
|
56
|
+
# @param flag_name [Symbol, String] The flag name (underscores → dashes)
|
|
57
|
+
# @param default [Object] Default value (required - determines type)
|
|
58
|
+
# @param user [Object, nil, :auto] User for targeting (default: current_user)
|
|
59
|
+
# @param context [Hash, nil] Additional context attributes
|
|
60
|
+
# @return [Object] The flag value
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# <%= subflag_value(:headline, default: "Welcome") %>
|
|
64
|
+
# <%= subflag_value(:max_items, user: nil, default: 10) %>
|
|
65
|
+
#
|
|
66
|
+
def subflag_value(flag_name, default:, user: :auto, context: nil)
|
|
67
|
+
resolved = resolve_user(user)
|
|
68
|
+
Subflag.flags(user: resolved, context: context).public_send(flag_name, default: default)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get a flag accessor, optionally for a specific user
|
|
72
|
+
#
|
|
73
|
+
# Automatically scoped to current_user if no user provided.
|
|
74
|
+
#
|
|
75
|
+
# @param user [Object, nil, :auto] User for targeting (default: current_user)
|
|
76
|
+
# @param context [Hash, nil] Additional context attributes
|
|
77
|
+
# @return [FlagAccessor] A flag accessor
|
|
78
|
+
#
|
|
79
|
+
# @example Using current_user automatically
|
|
80
|
+
# <% flags = subflag_for %>
|
|
81
|
+
# <% if flags.beta_feature? %>
|
|
82
|
+
# <h1><%= flags.welcome_message(default: "Hello!") %></h1>
|
|
83
|
+
# <% end %>
|
|
84
|
+
#
|
|
85
|
+
# @example Without user context
|
|
86
|
+
# <% flags = subflag_for(nil) %>
|
|
87
|
+
#
|
|
88
|
+
# @example With specific user
|
|
89
|
+
# <% flags = subflag_for(admin_user) %>
|
|
90
|
+
#
|
|
91
|
+
def subflag_for(user = :auto, context: nil)
|
|
92
|
+
resolved = resolve_user(user)
|
|
93
|
+
Subflag.flags(user: resolved, context: context)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Resolve user parameter - use current_user if :auto and available
|
|
99
|
+
#
|
|
100
|
+
# @param user [Object, nil, :auto]
|
|
101
|
+
# @return [Object, nil]
|
|
102
|
+
def resolve_user(user)
|
|
103
|
+
return user unless user == :auto
|
|
104
|
+
respond_to?(:current_user, true) ? current_user : nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Railtie for automatic Rails integration
|
|
6
|
+
#
|
|
7
|
+
# This handles:
|
|
8
|
+
# - Registering helpers in views AND controllers
|
|
9
|
+
# - Auto-configuring from credentials if available
|
|
10
|
+
#
|
|
11
|
+
class Railtie < ::Rails::Railtie
|
|
12
|
+
# Include helpers in views and controllers
|
|
13
|
+
initializer "subflag.helpers" do
|
|
14
|
+
ActiveSupport.on_load(:action_view) do
|
|
15
|
+
include Subflag::Rails::Helpers
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
19
|
+
include Subflag::Rails::Helpers
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
23
|
+
include Subflag::Rails::Helpers
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Auto-configure from credentials
|
|
28
|
+
initializer "subflag.configure" do
|
|
29
|
+
config.after_initialize do
|
|
30
|
+
next if Subflag::Rails.configuration.api_key
|
|
31
|
+
|
|
32
|
+
api_key = ::Rails.application.credentials.dig(:subflag, :api_key) ||
|
|
33
|
+
::Rails.application.credentials.subflag_api_key ||
|
|
34
|
+
ENV["SUBFLAG_API_KEY"]
|
|
35
|
+
|
|
36
|
+
if api_key
|
|
37
|
+
Subflag::Rails.configure do |c|
|
|
38
|
+
c.api_key = api_key
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Per-request cache for flag evaluations
|
|
6
|
+
#
|
|
7
|
+
# Caches flag values for the duration of a single request to avoid
|
|
8
|
+
# multiple API calls for the same flag.
|
|
9
|
+
#
|
|
10
|
+
# @example Enable in application.rb
|
|
11
|
+
# # config/application.rb
|
|
12
|
+
# config.middleware.use Subflag::Rails::RequestCache::Middleware
|
|
13
|
+
#
|
|
14
|
+
# @example Or in initializer
|
|
15
|
+
# # config/initializers/subflag.rb
|
|
16
|
+
# Rails.application.config.middleware.use Subflag::Rails::RequestCache::Middleware
|
|
17
|
+
#
|
|
18
|
+
module RequestCache
|
|
19
|
+
class << self
|
|
20
|
+
# Get a cached value or yield to fetch it
|
|
21
|
+
#
|
|
22
|
+
# @param cache_key [String] Unique key for this evaluation
|
|
23
|
+
# @yield Block to execute if not cached
|
|
24
|
+
# @return [Object] Cached or freshly fetched value
|
|
25
|
+
def fetch(cache_key, &block)
|
|
26
|
+
return yield unless enabled?
|
|
27
|
+
|
|
28
|
+
cache = current_cache
|
|
29
|
+
return cache[cache_key] if cache.key?(cache_key)
|
|
30
|
+
|
|
31
|
+
cache[cache_key] = yield
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if request caching is active
|
|
35
|
+
def enabled?
|
|
36
|
+
Thread.current[:subflag_request_cache].is_a?(Hash)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Start a new cache scope
|
|
40
|
+
def start
|
|
41
|
+
Thread.current[:subflag_request_cache] = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# End the cache scope
|
|
45
|
+
def clear
|
|
46
|
+
Thread.current[:subflag_request_cache] = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get current cache
|
|
50
|
+
def current_cache
|
|
51
|
+
Thread.current[:subflag_request_cache] ||= {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get cache stats for debugging
|
|
55
|
+
def stats
|
|
56
|
+
cache = current_cache
|
|
57
|
+
{ size: cache.size, keys: cache.keys }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Rack middleware for per-request caching
|
|
62
|
+
#
|
|
63
|
+
# Wraps each request in a cache scope so flag evaluations
|
|
64
|
+
# are cached for the duration of the request.
|
|
65
|
+
#
|
|
66
|
+
class Middleware
|
|
67
|
+
def initialize(app)
|
|
68
|
+
@app = app
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def call(env)
|
|
72
|
+
RequestCache.start
|
|
73
|
+
@app.call(env)
|
|
74
|
+
ensure
|
|
75
|
+
RequestCache.clear
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Test helpers for stubbing feature flags in specs
|
|
6
|
+
#
|
|
7
|
+
# @example RSpec setup
|
|
8
|
+
# # spec/rails_helper.rb
|
|
9
|
+
# require "subflag/rails/test_helpers"
|
|
10
|
+
# RSpec.configure do |config|
|
|
11
|
+
# config.include Subflag::Rails::TestHelpers
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Minitest setup
|
|
15
|
+
# # test/test_helper.rb
|
|
16
|
+
# require "subflag/rails/test_helpers"
|
|
17
|
+
# class ActiveSupport::TestCase
|
|
18
|
+
# include Subflag::Rails::TestHelpers
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Usage
|
|
22
|
+
# it "shows new checkout when enabled" do
|
|
23
|
+
# stub_subflag(:new_checkout, true)
|
|
24
|
+
# visit checkout_path
|
|
25
|
+
# expect(page).to have_content("New Checkout")
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# it "limits projects based on plan" do
|
|
29
|
+
# stub_subflag(:max_projects, 100)
|
|
30
|
+
# expect(subflag_value(:max_projects, default: 3)).to eq(100)
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
module TestHelpers
|
|
34
|
+
# Stub a flag to return a specific value
|
|
35
|
+
#
|
|
36
|
+
# @param flag_name [Symbol, String] The flag name (underscores or dashes)
|
|
37
|
+
# @param value [Object] The value to return
|
|
38
|
+
#
|
|
39
|
+
# @example Boolean
|
|
40
|
+
# stub_subflag(:new_checkout, true)
|
|
41
|
+
#
|
|
42
|
+
# @example String
|
|
43
|
+
# stub_subflag(:headline, "Welcome!")
|
|
44
|
+
#
|
|
45
|
+
# @example Integer
|
|
46
|
+
# stub_subflag(:max_projects, 100)
|
|
47
|
+
#
|
|
48
|
+
# @example Hash
|
|
49
|
+
# stub_subflag(:limits, { max_items: 50 })
|
|
50
|
+
#
|
|
51
|
+
def stub_subflag(flag_name, value)
|
|
52
|
+
flag_key = normalize_flag_key(flag_name)
|
|
53
|
+
stubbed_flags[flag_key] = value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Stub multiple flags at once
|
|
57
|
+
#
|
|
58
|
+
# @param flags [Hash] Flag names to values
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# stub_subflags(
|
|
62
|
+
# new_checkout: true,
|
|
63
|
+
# max_projects: 100,
|
|
64
|
+
# headline: "Welcome!"
|
|
65
|
+
# )
|
|
66
|
+
#
|
|
67
|
+
def stub_subflags(flags)
|
|
68
|
+
flags.each { |name, value| stub_subflag(name, value) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Clear all stubbed flags
|
|
72
|
+
def clear_stubbed_subflags
|
|
73
|
+
stubbed_flags.clear
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def stubbed_flags
|
|
79
|
+
@stubbed_flags ||= {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_flag_key(flag_name)
|
|
83
|
+
flag_name.to_s.tr("_", "-")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Stubbed client that returns test values
|
|
88
|
+
class StubbedClient
|
|
89
|
+
def initialize(stubs)
|
|
90
|
+
@stubs = stubs
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enabled?(flag_key, user: nil, context: nil, default: false)
|
|
94
|
+
@stubs.fetch(flag_key, default)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def value(flag_key, user: nil, context: nil, default:)
|
|
98
|
+
@stubs.fetch(flag_key, default)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def evaluate(flag_key, user: nil, context: nil, default:)
|
|
102
|
+
value = @stubs.fetch(flag_key, default)
|
|
103
|
+
EvaluationResult.new(
|
|
104
|
+
value: value,
|
|
105
|
+
variant: "stubbed",
|
|
106
|
+
reason: @stubs.key?(flag_key) ? :static : :default,
|
|
107
|
+
flag_key: flag_key
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Patch FlagAccessor to use stubbed values in tests
|
|
115
|
+
module Subflag
|
|
116
|
+
module Rails
|
|
117
|
+
class FlagAccessor
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
alias_method :original_client, :client
|
|
121
|
+
|
|
122
|
+
def client
|
|
123
|
+
if test_stubs_active?
|
|
124
|
+
StubbedClient.new(current_test_stubs)
|
|
125
|
+
else
|
|
126
|
+
original_client
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test_stubs_active?
|
|
131
|
+
Thread.current[:subflag_test_stubs].is_a?(Hash) &&
|
|
132
|
+
!Thread.current[:subflag_test_stubs].empty?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def current_test_stubs
|
|
136
|
+
Thread.current[:subflag_test_stubs] || {}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Update TestHelpers to use thread-local storage
|
|
141
|
+
module TestHelpers
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def stubbed_flags
|
|
145
|
+
Thread.current[:subflag_test_stubs] ||= {}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|