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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +181 -0
- data/lib/generators/has_states/install/install_generator.rb +25 -0
- data/lib/generators/has_states/install/templates/create_has_states_states.rb.erb +17 -0
- data/lib/generators/has_states/install/templates/initializer.rb.erb +42 -0
- data/lib/has_states/callback.rb +58 -0
- data/lib/has_states/configuration/model_configuration.rb +48 -0
- data/lib/has_states/configuration/state_type_configuration.rb +15 -0
- data/lib/has_states/configuration.rb +83 -0
- data/lib/has_states/railtie.rb +8 -0
- data/lib/has_states/state.rb +41 -0
- data/lib/has_states/stateable.rb +22 -0
- data/lib/has_states/version.rb +5 -0
- data/lib/has_states.rb +24 -0
- data/spec/dummy/Gemfile +40 -0
- data/spec/dummy/Gemfile.lock +286 -0
- data/spec/dummy/README.md +24 -0
- data/spec/dummy/Rakefile +8 -0
- data/spec/dummy/app/controllers/application_controller.rb +4 -0
- data/spec/dummy/app/jobs/application_job.rb +9 -0
- data/spec/dummy/app/models/application_record.rb +5 -0
- data/spec/dummy/app/models/company.rb +3 -0
- data/spec/dummy/app/models/user.rb +3 -0
- data/spec/dummy/bin/brakeman +9 -0
- data/spec/dummy/bin/dev +4 -0
- data/spec/dummy/bin/docker-entrypoint +14 -0
- data/spec/dummy/bin/rails +6 -0
- data/spec/dummy/bin/rake +6 -0
- data/spec/dummy/bin/rubocop +10 -0
- data/spec/dummy/bin/setup +36 -0
- data/spec/dummy/bin/thrust +7 -0
- data/spec/dummy/config/application.rb +27 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/credentials.yml.enc +1 -0
- data/spec/dummy/config/database.yml +37 -0
- data/spec/dummy/config/environment.rb +7 -0
- data/spec/dummy/config/environments/development.rb +54 -0
- data/spec/dummy/config/environments/production.rb +69 -0
- data/spec/dummy/config/environments/test.rb +44 -0
- data/spec/dummy/config/initializers/cors.rb +18 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/spec/dummy/config/initializers/inflections.rb +18 -0
- data/spec/dummy/config/locales/en.yml +31 -0
- data/spec/dummy/config/master.key +1 -0
- data/spec/dummy/config/puma.rb +43 -0
- data/spec/dummy/config/routes.rb +12 -0
- data/spec/dummy/config.ru +8 -0
- data/spec/dummy/db/migrate/20241221171423_create_test_models.rb +15 -0
- data/spec/dummy/db/migrate/20241221183116_create_has_states_tables.rb +19 -0
- data/spec/dummy/db/schema.rb +40 -0
- data/spec/dummy/db/seeds.rb +11 -0
- data/spec/dummy/log/development.log +142 -0
- data/spec/dummy/log/test.log +16652 -0
- data/spec/dummy/public/robots.txt +1 -0
- data/spec/dummy/storage/development.sqlite3 +0 -0
- data/spec/dummy/storage/test.sqlite3 +0 -0
- data/spec/dummy/tmp/local_secret.txt +1 -0
- data/spec/factories/has_states.rb +19 -0
- data/spec/generators/has_states/install_generator_spec.rb +27 -0
- data/spec/generators/tmp/config/initializers/has_states.rb +42 -0
- data/spec/generators/tmp/db/migrate/20241223020432_create_has_states_states.rb +17 -0
- data/spec/has_states/callback_spec.rb +92 -0
- data/spec/has_states/configuration_spec.rb +161 -0
- data/spec/has_states/state_spec.rb +264 -0
- data/spec/has_states_spec.rb +52 -0
- data/spec/rails_helper.rb +18 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/database_cleaner.rb +17 -0
- data/spec/support/factory_bot.rb +8 -0
- data/spec/support/shoulda_matchers.rb +10 -0
- data/spec/tmp/config/initializers/has_states.rb +12 -0
- data/spec/tmp/db/migrate/20241223004024_create_has_states_states.rb +20 -0
- 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
|
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
|