stateful_models 0.0.1

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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +181 -0
  5. data/lib/generators/has_states/install/install_generator.rb +25 -0
  6. data/lib/generators/has_states/install/templates/create_has_states_states.rb.erb +17 -0
  7. data/lib/generators/has_states/install/templates/initializer.rb.erb +42 -0
  8. data/lib/has_states/callback.rb +58 -0
  9. data/lib/has_states/configuration/model_configuration.rb +48 -0
  10. data/lib/has_states/configuration/state_type_configuration.rb +15 -0
  11. data/lib/has_states/configuration.rb +83 -0
  12. data/lib/has_states/railtie.rb +8 -0
  13. data/lib/has_states/state.rb +41 -0
  14. data/lib/has_states/stateable.rb +22 -0
  15. data/lib/has_states/version.rb +5 -0
  16. data/lib/has_states.rb +24 -0
  17. data/spec/dummy/Gemfile +40 -0
  18. data/spec/dummy/Gemfile.lock +286 -0
  19. data/spec/dummy/README.md +24 -0
  20. data/spec/dummy/Rakefile +8 -0
  21. data/spec/dummy/app/controllers/application_controller.rb +4 -0
  22. data/spec/dummy/app/jobs/application_job.rb +9 -0
  23. data/spec/dummy/app/models/application_record.rb +5 -0
  24. data/spec/dummy/app/models/company.rb +3 -0
  25. data/spec/dummy/app/models/user.rb +3 -0
  26. data/spec/dummy/bin/brakeman +9 -0
  27. data/spec/dummy/bin/dev +4 -0
  28. data/spec/dummy/bin/docker-entrypoint +14 -0
  29. data/spec/dummy/bin/rails +6 -0
  30. data/spec/dummy/bin/rake +6 -0
  31. data/spec/dummy/bin/rubocop +10 -0
  32. data/spec/dummy/bin/setup +36 -0
  33. data/spec/dummy/bin/thrust +7 -0
  34. data/spec/dummy/config/application.rb +27 -0
  35. data/spec/dummy/config/boot.rb +5 -0
  36. data/spec/dummy/config/credentials.yml.enc +1 -0
  37. data/spec/dummy/config/database.yml +37 -0
  38. data/spec/dummy/config/environment.rb +7 -0
  39. data/spec/dummy/config/environments/development.rb +54 -0
  40. data/spec/dummy/config/environments/production.rb +69 -0
  41. data/spec/dummy/config/environments/test.rb +44 -0
  42. data/spec/dummy/config/initializers/cors.rb +18 -0
  43. data/spec/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  44. data/spec/dummy/config/initializers/inflections.rb +18 -0
  45. data/spec/dummy/config/locales/en.yml +31 -0
  46. data/spec/dummy/config/master.key +1 -0
  47. data/spec/dummy/config/puma.rb +43 -0
  48. data/spec/dummy/config/routes.rb +12 -0
  49. data/spec/dummy/config.ru +8 -0
  50. data/spec/dummy/db/migrate/20241221171423_create_test_models.rb +15 -0
  51. data/spec/dummy/db/migrate/20241221183116_create_has_states_tables.rb +19 -0
  52. data/spec/dummy/db/schema.rb +40 -0
  53. data/spec/dummy/db/seeds.rb +11 -0
  54. data/spec/dummy/log/development.log +142 -0
  55. data/spec/dummy/log/test.log +16652 -0
  56. data/spec/dummy/public/robots.txt +1 -0
  57. data/spec/dummy/storage/development.sqlite3 +0 -0
  58. data/spec/dummy/storage/test.sqlite3 +0 -0
  59. data/spec/dummy/tmp/local_secret.txt +1 -0
  60. data/spec/factories/has_states.rb +19 -0
  61. data/spec/generators/has_states/install_generator_spec.rb +27 -0
  62. data/spec/generators/tmp/config/initializers/has_states.rb +42 -0
  63. data/spec/generators/tmp/db/migrate/20241223020432_create_has_states_states.rb +17 -0
  64. data/spec/has_states/callback_spec.rb +92 -0
  65. data/spec/has_states/configuration_spec.rb +161 -0
  66. data/spec/has_states/state_spec.rb +264 -0
  67. data/spec/has_states_spec.rb +52 -0
  68. data/spec/rails_helper.rb +18 -0
  69. data/spec/spec_helper.rb +16 -0
  70. data/spec/support/database_cleaner.rb +17 -0
  71. data/spec/support/factory_bot.rb +8 -0
  72. data/spec/support/shoulda_matchers.rb +10 -0
  73. data/spec/tmp/config/initializers/has_states.rb +12 -0
  74. data/spec/tmp/db/migrate/20241223004024_create_has_states_states.rb +20 -0
  75. metadata +122 -0
@@ -0,0 +1 @@
1
+ # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
Binary file
@@ -0,0 +1 @@
1
+ d932584e11af23c6015a503f0dfd79d657604b56d2be374b64c230b372e42872e55a7820b77cee6c02bef13a11870077718ebc0ffdf7efb450efb5cdf952cdc6
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spec/factories/has_states.rb
4
+ FactoryBot.define do
5
+ factory :state, class: 'HasStates::State' do
6
+ association :stateable, factory: :user
7
+ state_type { 'kyc' }
8
+ status { 'pending' }
9
+ metadata { {} }
10
+ end
11
+
12
+ factory :user do
13
+ sequence(:name) { |n| "User #{n}" }
14
+ end
15
+
16
+ factory :company do
17
+ sequence(:name) { |n| "Company #{n}" }
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+ require 'generators/has_states/install/install_generator'
5
+
6
+ RSpec.describe HasStates::InstallGenerator do
7
+ let(:destination) { File.expand_path('../tmp', __dir__) }
8
+
9
+ before do
10
+ FileUtils.rm_rf(destination)
11
+ FileUtils.mkdir_p(destination)
12
+ end
13
+
14
+ it 'creates migration and initializer files' do
15
+ generator = described_class.new
16
+ generator.destination_root = destination
17
+ generator.install
18
+
19
+ # Check migration exists
20
+ migration = Dir[File.join(destination, 'db/migrate/*_create_has_states_states.rb')].first
21
+ expect(migration).to be_present
22
+
23
+ # Check initializer exists
24
+ initializer = File.join(destination, 'config/initializers/has_states.rb')
25
+ expect(File.exist?(initializer)).to be true
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ HasStates.configure do |config|
4
+ # Configure your models and their state types below
5
+ #
6
+ # Example configuration:
7
+ #
8
+ # config.configure_model User do |model|
9
+ # # KYC state type with its allowed statuses
10
+ # model.state_type :kyc do |type|
11
+ # type.statuses = [
12
+ # 'pending', # Initial state
13
+ # 'documents_required', # Waiting for user documents
14
+ # 'under_review', # Documents being reviewed
15
+ # 'approved', # KYC process completed successfully
16
+ # 'rejected' # KYC process failed
17
+ # ]
18
+ # end
19
+ #
20
+ # # Onboarding state type with different statuses
21
+ # model.state_type :onboarding do |type|
22
+ # type.statuses = [
23
+ # 'pending', # Just started
24
+ # 'email_verified', # Email verification complete
25
+ # 'profile_complete', # User filled all required fields
26
+ # 'completed' # Onboarding finished
27
+ # ]
28
+ # end
29
+ # end
30
+ #
31
+ # config.configure_model Company do |model|
32
+ # model.state_type :verification do |type|
33
+ # type.statuses = [
34
+ # 'pending',
35
+ # 'documents_submitted',
36
+ # 'under_review',
37
+ # 'verified',
38
+ # 'rejected'
39
+ # ]
40
+ # end
41
+ # end
42
+ end
@@ -0,0 +1,17 @@
1
+ class CreateHasStatesStates < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :has_states_states do |t|
4
+ t.string :state_type
5
+ t.string :status, null: false
6
+
7
+ t.json :metadata, null: false, default: {}
8
+
9
+ t.references :stateable, polymorphic: true, null: false
10
+
11
+ t.datetime :completed_at
12
+ t.timestamps
13
+
14
+ t.index %i[stateable_type stateable_id]
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ # spec/has_states/callback_spec.rb
6
+ RSpec.describe HasStates::Callback do
7
+ let(:state) { double('state', state_type: 'kyc', status: 'completed') }
8
+ let(:block) { ->(_s) { :called } }
9
+
10
+ describe '#matches?' do
11
+ context 'with state type only' do
12
+ let(:callback) { described_class.new('kyc', {}, block) }
13
+
14
+ it 'matches on state type' do
15
+ expect(callback.matches?(state)).to be true
16
+ end
17
+
18
+ it "doesn't match different state type" do
19
+ different_state = double('state', state_type: 'identity')
20
+ expect(callback.matches?(different_state)).to be false
21
+ end
22
+ end
23
+
24
+ context 'with status conditions' do
25
+ let(:callback) { described_class.new('kyc', { to: 'completed' }, block) }
26
+
27
+ it 'matches when conditions are met' do
28
+ expect(callback.matches?(state)).to be true
29
+ end
30
+
31
+ it "doesn't match when conditions aren't met" do
32
+ pending_state = double('state', state_type: 'kyc', status: 'pending')
33
+ expect(callback.matches?(pending_state)).to be false
34
+ end
35
+ end
36
+
37
+ context 'with from condition' do
38
+ let(:callback) { described_class.new('kyc', { from: 'pending' }, block) }
39
+ let(:state_with_history) do
40
+ double('state',
41
+ state_type: 'kyc',
42
+ status: 'completed',
43
+ status_before_last_save: 'pending')
44
+ end
45
+
46
+ it 'matches when previous status matches' do
47
+ expect(callback.matches?(state_with_history)).to be true
48
+ end
49
+ end
50
+ end
51
+
52
+ describe '#call' do
53
+ let(:callback) { described_class.new('kyc', {}, block) }
54
+
55
+ it 'executes the stored block with the state' do
56
+ expect(callback.call(state)).to eq(:called)
57
+ end
58
+ end
59
+
60
+ describe 'equality' do
61
+ let(:block1) { ->(_s) { :called } }
62
+ let(:block2) { ->(_s) { :called } }
63
+
64
+ it 'considers callbacks equal if they have the same state_type, conditions, and block' do
65
+ callback1 = described_class.new('kyc', { to: 'completed' }, block1)
66
+ callback2 = described_class.new('kyc', { to: 'completed' }, block1)
67
+
68
+ expect(callback1).to eq(callback2)
69
+ end
70
+
71
+ it 'considers callbacks different if they have different blocks' do
72
+ callback1 = described_class.new('kyc', { to: 'completed' }, block1)
73
+ callback2 = described_class.new('kyc', { to: 'completed' }, block2)
74
+
75
+ expect(callback1).not_to eq(callback2)
76
+ end
77
+
78
+ it 'considers callbacks different if they have different conditions' do
79
+ callback1 = described_class.new('kyc', { to: 'completed' }, block1)
80
+ callback2 = described_class.new('kyc', { to: 'pending' }, block1)
81
+
82
+ expect(callback1).not_to eq(callback2)
83
+ end
84
+
85
+ it 'considers callbacks different if they have different state types' do
86
+ callback1 = described_class.new('kyc', { to: 'completed' }, block1)
87
+ callback2 = described_class.new('identity', { to: 'completed' }, block1)
88
+
89
+ expect(callback1).not_to eq(callback2)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spec/has_states/configuration_spec.rb
4
+ require 'rails_helper'
5
+
6
+ RSpec.describe HasStates::Configuration do
7
+ let(:configuration) { described_class.instance }
8
+
9
+ before do
10
+ configuration.clear_callbacks!
11
+ configuration.model_configurations.clear
12
+ end
13
+
14
+ describe 'model configuration' do
15
+ it 'configures models with their state types and statuses' do
16
+ configuration.configure_model User do |model|
17
+ model.state_type :kyc do |type|
18
+ type.statuses = %w[pending completed]
19
+ end
20
+ end
21
+
22
+ expect(configuration.valid_status?(User, 'kyc', 'pending')).to be true
23
+ expect(configuration.valid_status?(User, 'kyc', 'invalid')).to be false
24
+ end
25
+
26
+ it 'allows different statuses for different state types' do
27
+ configuration.configure_model User do |model|
28
+ model.state_type :kyc do |type|
29
+ type.statuses = %w[pending verified]
30
+ end
31
+
32
+ model.state_type :onboarding do |type|
33
+ type.statuses = %w[started completed]
34
+ end
35
+ end
36
+
37
+ expect(configuration.valid_status?(User, 'kyc', 'verified')).to be true
38
+ expect(configuration.valid_status?(User, 'onboarding', 'verified')).to be false
39
+ expect(configuration.valid_status?(User, 'onboarding', 'completed')).to be true
40
+ end
41
+
42
+ it 'allows different configurations for different models' do
43
+ configuration.configure_model User do |model|
44
+ model.state_type :kyc do |type|
45
+ type.statuses = %w[pending verified]
46
+ end
47
+ end
48
+
49
+ configuration.configure_model Company do |model|
50
+ model.state_type :onboarding do |type|
51
+ type.statuses = %w[pending active]
52
+ end
53
+ end
54
+
55
+ expect(configuration.valid_state_type?(User, 'kyc')).to be true
56
+ expect(configuration.valid_state_type?(Company, 'kyc')).to be false
57
+ expect(configuration.valid_state_type?(Company, 'onboarding')).to be true
58
+ end
59
+
60
+ it 'automatically includes Stateable in configured models' do
61
+ configuration.configure_model User do |model|
62
+ model.state_type :kyc do |type|
63
+ type.statuses = ['pending']
64
+ end
65
+ end
66
+
67
+ expect(User.included_modules).to include(HasStates::Stateable)
68
+ end
69
+
70
+ it 'raises error for non-ActiveRecord models' do
71
+ expect do
72
+ configuration.configure_model String do |model|
73
+ model.state_type :test
74
+ end
75
+ end.to raise_error(ArgumentError, /must be an ActiveRecord model/)
76
+ end
77
+ end
78
+
79
+ describe 'callbacks' do
80
+ let(:state_completed) { create(:state, state_type: 'kyc', status: 'completed') }
81
+
82
+ before do
83
+ configuration.configure_model User do |model|
84
+ model.state_type :kyc do |type|
85
+ type.statuses = %w[pending completed failed]
86
+ end
87
+ end
88
+
89
+ configuration.on(:kyc, id: :failed_callback, to: 'failed') { |_s| :kyc_failed }
90
+ configuration.on(:kyc, id: :pending_callback, to: 'pending') { |_s| :kyc_pending }
91
+ configuration.on(:kyc, id: :complete_callback, to: 'completed') { |_s| :kyc_completed }
92
+ end
93
+
94
+ describe '#on' do
95
+ it 'registers callbacks' do
96
+ expect(configuration.callbacks.size).to eq(3)
97
+ end
98
+
99
+ it 'matches callbacks for the right state' do
100
+ matching = configuration.matching_callbacks(state_completed)
101
+
102
+ expect(matching.length).to eq(1)
103
+ end
104
+
105
+ it 'registers callbacks with auto-generated ids' do
106
+ configuration.on(:kyc) { |_s| :some_action }
107
+ expect(configuration.callbacks.keys.last).to match(/\Acallback_\d+\z/)
108
+ end
109
+
110
+ it 'registers callbacks with custom ids' do
111
+ configuration.on(:kyc, id: :my_custom_callback) { |_s| :some_action }
112
+ expect(configuration.callbacks.keys).to include(:my_custom_callback)
113
+ end
114
+
115
+ it 'converts string ids to symbols' do
116
+ configuration.on(:kyc, id: 'my_string_id') { |_s| :some_action }
117
+ expect(configuration.callbacks.keys).to include(:my_string_id)
118
+ end
119
+ end
120
+
121
+ describe '#off' do
122
+ it 'removes a callback by id' do
123
+ configuration.on(:kyc, id: :removable) { |_s| :some_action }
124
+ expect { configuration.off(:removable) }
125
+ .to change { configuration.callbacks.size }.by(-1)
126
+ end
127
+
128
+ it 'removes a callback by callback object' do
129
+ callback = configuration.on(:kyc) { |_s| :some_action }
130
+ expect { configuration.off(callback) }
131
+ .to change { configuration.callbacks.size }.by(-1)
132
+ end
133
+
134
+ it 'properly removes callback when given a callback object' do
135
+ callback = configuration.on(:kyc, id: :test) { |_s| :some_action }
136
+ expect(configuration.callbacks.values).to include(callback)
137
+
138
+ configuration.off(callback)
139
+ expect(configuration.callbacks.values).not_to include(callback)
140
+ end
141
+ end
142
+
143
+ describe '#clear_callbacks!' do
144
+ it 'removes all callbacks' do
145
+ expect { configuration.clear_callbacks! }.to change { configuration.callbacks.size }.to(0)
146
+ end
147
+ end
148
+
149
+ describe '#matching_callbacks' do
150
+ it 'finds callbacks matching state type and conditions regardless of id' do
151
+ configuration.clear_callbacks!
152
+ configuration.on(:kyc, id: :complete_callback, to: 'completed') { |_s| :kyc_completed }
153
+
154
+ matching = configuration.matching_callbacks(state_completed)
155
+
156
+ expect(matching.size).to eq(1)
157
+ expect(matching.first.call(state_completed)).to eq(:kyc_completed)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe HasStates::State, type: :model do
6
+ subject { build(:state) }
7
+
8
+ before do
9
+ HasStates.configuration.clear_callbacks!
10
+ HasStates.configuration.model_configurations.clear
11
+
12
+ HasStates.configure do |config|
13
+ config.configure_model User do |model|
14
+ model.state_type :kyc do |type|
15
+ type.statuses = %w[pending completed]
16
+ end
17
+
18
+ model.state_type :onboarding do |type|
19
+ type.statuses = %w[pending completed]
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ describe 'validations' do
26
+ context 'status' do
27
+ it 'validates status presence' do
28
+ expect(subject).not_to allow_value(nil).for(:status)
29
+ end
30
+
31
+ it 'validates status is configured' do
32
+ expect(subject).to allow_value('pending').for(:status)
33
+ end
34
+
35
+ it 'validates status that is not configured' do
36
+ expect(subject).not_to allow_value('invalid').for(:status)
37
+ end
38
+ end
39
+
40
+ context 'state_type' do
41
+ it 'validates state_type presence' do
42
+ expect(subject).not_to allow_value(nil).for(:state_type)
43
+ end
44
+
45
+ it 'validates state_type is configured' do
46
+ expect(subject).to allow_value('kyc').for(:state_type)
47
+ end
48
+
49
+ it 'validates state_type that is not configured' do
50
+ expect(subject).not_to allow_value('invalid').for(:state_type)
51
+ end
52
+ end
53
+ end
54
+
55
+ describe 'scopes' do
56
+ let(:state) { create(:state, state_type: 'kyc') }
57
+
58
+ it 'defines a scope for each state type' do
59
+ expect(described_class.kyc).to eq([state])
60
+ end
61
+ end
62
+
63
+ describe 'instance methods' do
64
+ let(:state) { create(:state, state_type: 'kyc', status: 'pending') }
65
+ let(:model_config) { HasStates.configuration.model_configurations[User] }
66
+ let(:all_statuses) do
67
+ model_config.state_types.values.flat_map(&:statuses).uniq
68
+ end
69
+
70
+ it 'defines predicate methods for all configured statuses' do
71
+ all_statuses.each do |status|
72
+ expect(state).to respond_to("#{status}?")
73
+ end
74
+ end
75
+
76
+ it 'returns true if the status is pending' do
77
+ expect(state.pending?).to be(true)
78
+ end
79
+
80
+ it 'returns false if the status is not completed' do
81
+ expect(state.completed?).to be(false)
82
+ end
83
+ end
84
+
85
+ describe 'metadata' do
86
+ let(:user) { FactoryBot.create(:user) }
87
+
88
+ it 'stores and retrieves simple metadata' do
89
+ state = user.add_state('kyc', metadata: { reason: 'documents_missing' })
90
+ expect(state.metadata['reason']).to eq('documents_missing')
91
+ end
92
+
93
+ it 'stores and retrieves nested metadata' do
94
+ metadata = {
95
+ documents: {
96
+ passport: { status: 'rejected', reason: 'expired' },
97
+ utility_bill: { status: 'pending' }
98
+ }
99
+ }
100
+
101
+ state = user.add_state('kyc', metadata: metadata)
102
+ expect(state.metadata['documents']['passport']['reason']).to eq('expired')
103
+ end
104
+
105
+ it 'handles arrays in metadata' do
106
+ metadata = {
107
+ missing_documents: %w[passport utility_bill],
108
+ review_history: [
109
+ { date: '2024-01-01', status: 'rejected' },
110
+ { date: '2024-01-02', status: 'approved' }
111
+ ]
112
+ }
113
+
114
+ state = user.add_state('kyc', metadata: metadata)
115
+ expect(state.metadata['missing_documents']).to eq(%w[passport utility_bill])
116
+ expect(state.metadata['review_history'].size).to eq(2)
117
+ end
118
+
119
+ it 'handles different data types' do
120
+ metadata = {
121
+ string_value: 'test',
122
+ integer_value: 42,
123
+ float_value: 42.5,
124
+ boolean_value: true,
125
+ null_value: nil,
126
+ date_value: '2024-01-01'
127
+ }
128
+
129
+ state = user.add_state('kyc', metadata: metadata)
130
+ expect(state.metadata['string_value']).to eq('test')
131
+ expect(state.metadata['integer_value']).to eq(42)
132
+ expect(state.metadata['float_value']).to eq(42.5)
133
+ expect(state.metadata['boolean_value']).to be true
134
+ expect(state.metadata['null_value']).to be_nil
135
+ expect(state.metadata['date_value']).to eq('2024-01-01')
136
+ end
137
+
138
+ it 'defaults to empty hash when no metadata provided' do
139
+ state = user.add_state('kyc')
140
+ expect(state.metadata).to eq({})
141
+ end
142
+
143
+ it 'persists metadata across database reads' do
144
+ state = user.add_state('kyc', metadata: { key: 'value' })
145
+ reloaded_state = HasStates::State.find(state.id)
146
+ expect(reloaded_state.metadata['key']).to eq('value')
147
+ end
148
+ end
149
+
150
+ describe 'callbacks' do
151
+ let(:user) { create(:user) }
152
+ let(:callback_executed) { false }
153
+
154
+ before do
155
+ HasStates.configure do |config|
156
+ config.configure_model User do |model|
157
+ model.state_type :onboarding do |type|
158
+ type.statuses = %w[pending completed]
159
+ end
160
+ end
161
+
162
+ # Register a callback for when onboarding is completed
163
+ config.on(:onboarding, id: :complete_onboarding, to: 'completed') do |state|
164
+ state.stateable.update!(name: 'Onboarded User')
165
+ end
166
+ end
167
+ end
168
+
169
+ it 'executes callback when state changes to completed' do
170
+ # Create initial pending state
171
+ state = user.add_state('onboarding', status: 'pending')
172
+ expect(user.name).not_to eq('Onboarded User')
173
+
174
+ # Update to completed
175
+ state.update!(status: 'completed')
176
+
177
+ # Verify callback was executed
178
+ expect(user.reload.name).to eq('Onboarded User')
179
+ end
180
+
181
+ it 'does not execute callback for other status changes' do
182
+ state = user.add_state('onboarding', status: 'completed')
183
+ user.update!(name: 'Original Name')
184
+
185
+ # Update to pending
186
+ state.update!(status: 'pending')
187
+
188
+ # Verify callback was not executed
189
+ expect(user.reload.name).to eq('Original Name')
190
+ end
191
+ end
192
+
193
+ describe 'limited execution callbacks' do
194
+ let(:user) { create(:user) }
195
+
196
+ before do
197
+ # Clear ALL configuration
198
+ HasStates.configuration.clear_callbacks!
199
+ HasStates.configuration.model_configurations.clear
200
+
201
+ # Set up fresh configuration
202
+ HasStates.configure do |config|
203
+ config.configure_model User do |model|
204
+ model.state_type :onboarding do |type|
205
+ type.statuses = %w[pending completed]
206
+ end
207
+ end
208
+ end
209
+
210
+ @execution_count = 0
211
+ end
212
+
213
+ it 'executes callback only specified number of times' do
214
+ HasStates.configure do |config|
215
+ config.on(:onboarding, id: :counter, to: 'completed', times: 2) do |state|
216
+ state.stateable.update!(name: "Execution #{@execution_count += 1}")
217
+ end
218
+ end
219
+
220
+ # First execution
221
+ state1 = user.add_state('onboarding', status: 'pending')
222
+ state1.update!(status: 'completed')
223
+ expect(user.reload.name).to eq('Execution 1')
224
+
225
+ # Second execution
226
+ state2 = user.add_state('onboarding', status: 'pending')
227
+ state2.update!(status: 'completed')
228
+ expect(user.reload.name).to eq('Execution 2')
229
+
230
+ # Third execution - callback should be expired
231
+ user.update!(name: 'Final Name')
232
+ state3 = user.add_state('onboarding', status: 'pending')
233
+ state3.update!(status: 'completed')
234
+ expect(user.reload.name).to eq('Final Name') # Name shouldn't change
235
+ end
236
+
237
+ it 'keeps callback active indefinitely when times is not specified' do
238
+ HasStates.configure do |config|
239
+ config.on(:onboarding, id: :infinite, to: 'completed') do |state|
240
+ state.stateable.update!(name: "Execution #{@execution_count += 1}")
241
+ end
242
+ end
243
+
244
+ 3.times do |i|
245
+ state = user.add_state('onboarding', status: 'pending')
246
+ state.update!(status: 'completed')
247
+ expect(user.reload.name).to eq("Execution #{i + 1}")
248
+ end
249
+ end
250
+
251
+ it 'removes expired callbacks from configuration' do
252
+ HasStates.configure do |config|
253
+ config.on(:onboarding, id: :one_time, to: 'completed', times: 1) do |state|
254
+ state.stateable.update!(name: 'Executed')
255
+ end
256
+ end
257
+
258
+ expect do
259
+ state = user.add_state('onboarding', status: 'pending')
260
+ state.update!(status: 'completed')
261
+ end.to change { HasStates.configuration.callbacks.size }.from(1).to(0)
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe HasStates do
4
+ it 'has a version number' do
5
+ expect(HasStates::VERSION).not_to be_nil
6
+ end
7
+
8
+ it 'has a configuration' do
9
+ expect(described_class.configuration).to be_a(HasStates::Configuration)
10
+ end
11
+
12
+ describe 'configuration' do
13
+ before do
14
+ described_class.configure do |config|
15
+ # User configuration
16
+ config.configure_model User do |model|
17
+ model.state_type :kyc do |type|
18
+ type.statuses = %w[pending completed]
19
+ end
20
+
21
+ model.state_type :onboarding do |type|
22
+ type.statuses = %w[started finished]
23
+ end
24
+ end
25
+
26
+ # Company configuration
27
+ config.configure_model Company do |model|
28
+ model.state_type :procurement do |type|
29
+ type.statuses = %w[in_progress completed failed]
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ it 'configures the state types for User' do
36
+ expect(described_class.configuration.state_types_for(User).keys).to eq(%w[kyc onboarding])
37
+ end
38
+
39
+ it 'configures the statuses for User' do
40
+ expect(described_class.configuration.statuses_for(User, 'kyc')).to eq(%w[pending completed])
41
+ expect(described_class.configuration.statuses_for(User, 'onboarding')).to eq(%w[started finished])
42
+ end
43
+
44
+ it 'configures the state types for Company' do
45
+ expect(described_class.configuration.state_types_for(Company).keys).to eq(['procurement'])
46
+ end
47
+
48
+ it 'configures the statuses for Company' do
49
+ expect(described_class.configuration.statuses_for(Company, 'procurement')).to eq(%w[in_progress completed failed])
50
+ end
51
+ end
52
+ end