meta_states 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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +255 -0
  5. data/lib/generators/meta_states/install/install_generator.rb +43 -0
  6. data/lib/generators/meta_states/install/templates/create_indexes_on_meta_states_states.rb.erb +11 -0
  7. data/lib/generators/meta_states/install/templates/create_meta_states_states.rb.erb +18 -0
  8. data/lib/generators/meta_states/install/templates/initializer.rb.erb +42 -0
  9. data/lib/meta_states/base.rb +70 -0
  10. data/lib/meta_states/callback.rb +58 -0
  11. data/lib/meta_states/configuration/model_configuration.rb +64 -0
  12. data/lib/meta_states/configuration/state_type_configuration.rb +17 -0
  13. data/lib/meta_states/configuration.rb +142 -0
  14. data/lib/meta_states/railtie.rb +8 -0
  15. data/lib/meta_states/state.rb +5 -0
  16. data/lib/meta_states/stateable.rb +26 -0
  17. data/lib/meta_states/version.rb +5 -0
  18. data/lib/meta_states.rb +25 -0
  19. data/spec/dummy/Gemfile +40 -0
  20. data/spec/dummy/Gemfile.lock +308 -0
  21. data/spec/dummy/README.md +24 -0
  22. data/spec/dummy/Rakefile +8 -0
  23. data/spec/dummy/app/controllers/application_controller.rb +4 -0
  24. data/spec/dummy/app/jobs/application_job.rb +9 -0
  25. data/spec/dummy/app/models/application_record.rb +5 -0
  26. data/spec/dummy/app/models/company.rb +3 -0
  27. data/spec/dummy/app/models/user.rb +3 -0
  28. data/spec/dummy/bin/brakeman +9 -0
  29. data/spec/dummy/bin/dev +4 -0
  30. data/spec/dummy/bin/docker-entrypoint +14 -0
  31. data/spec/dummy/bin/rails +6 -0
  32. data/spec/dummy/bin/rake +6 -0
  33. data/spec/dummy/bin/rubocop +10 -0
  34. data/spec/dummy/bin/setup +36 -0
  35. data/spec/dummy/bin/thrust +7 -0
  36. data/spec/dummy/config/application.rb +27 -0
  37. data/spec/dummy/config/boot.rb +5 -0
  38. data/spec/dummy/config/credentials.yml.enc +1 -0
  39. data/spec/dummy/config/database.yml +37 -0
  40. data/spec/dummy/config/environment.rb +7 -0
  41. data/spec/dummy/config/environments/development.rb +54 -0
  42. data/spec/dummy/config/environments/production.rb +69 -0
  43. data/spec/dummy/config/environments/test.rb +44 -0
  44. data/spec/dummy/config/initializers/cors.rb +18 -0
  45. data/spec/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  46. data/spec/dummy/config/initializers/inflections.rb +18 -0
  47. data/spec/dummy/config/initializers/meta_states.rb +45 -0
  48. data/spec/dummy/config/locales/en.yml +31 -0
  49. data/spec/dummy/config/master.key +1 -0
  50. data/spec/dummy/config/puma.rb +43 -0
  51. data/spec/dummy/config/routes.rb +12 -0
  52. data/spec/dummy/config.ru +8 -0
  53. data/spec/dummy/db/migrate/20241221171423_create_test_models.rb +15 -0
  54. data/spec/dummy/db/migrate/20241223212128_create_has_states_states.rb +18 -0
  55. data/spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb +10 -0
  56. data/spec/dummy/db/schema.rb +44 -0
  57. data/spec/dummy/db/seeds.rb +11 -0
  58. data/spec/dummy/log/development.log +416 -0
  59. data/spec/dummy/log/test.log +55143 -0
  60. data/spec/dummy/public/robots.txt +1 -0
  61. data/spec/dummy/storage/development.sqlite3 +0 -0
  62. data/spec/dummy/storage/test.sqlite3 +0 -0
  63. data/spec/dummy/tmp/local_secret.txt +1 -0
  64. data/spec/factories/meta_states.rb +19 -0
  65. data/spec/generators/meta_states/install_generator_spec.rb +27 -0
  66. data/spec/generators/templates/config/initializers/meta_states.rb +42 -0
  67. data/spec/generators/templates/db/migrate/20250605170637_create_indexes_on_meta_states_states.rb +11 -0
  68. data/spec/generators/templates/db/migrate/20250605170637_create_meta_states_states.rb +18 -0
  69. data/spec/meta_states/callback_spec.rb +92 -0
  70. data/spec/meta_states/configuration_spec.rb +218 -0
  71. data/spec/meta_states/state_limit_spec.rb +107 -0
  72. data/spec/meta_states/state_metadata_schema_spec.rb +75 -0
  73. data/spec/meta_states/state_spec.rb +349 -0
  74. data/spec/meta_states/stateable_spec.rb +183 -0
  75. data/spec/meta_states_spec.rb +52 -0
  76. data/spec/rails_helper.rb +19 -0
  77. data/spec/spec_helper.rb +16 -0
  78. data/spec/support/database_cleaner.rb +17 -0
  79. data/spec/support/factory_bot.rb +8 -0
  80. data/spec/support/shoulda_matchers.rb +10 -0
  81. data/spec/tmp/config/initializers/has_states.rb +12 -0
  82. data/spec/tmp/db/migrate/20241223004024_create_has_states_states.rb +20 -0
  83. metadata +141 -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/meta_states.rb
4
+ FactoryBot.define do
5
+ factory :state, class: 'MetaStates::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/meta_states/install/install_generator'
5
+
6
+ RSpec.describe MetaStates::InstallGenerator do
7
+ let(:destination) { File.expand_path('../templates', __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_meta_states_states.rb')].first
21
+ expect(migration).to be_present
22
+
23
+ # Check initializer exists
24
+ initializer = File.join(destination, 'config/initializers/meta_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
+ MetaStates.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,11 @@
1
+ class CreateIndexesOnMetaStatesStates < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_table :meta_states_states do |t|
4
+ t.index %i[stateable_id state_type]
5
+ t.index %i[stateable_id state_type status]
6
+ t.index %i[stateable_id state_type created_at]
7
+ t.index %i[stateable_id state_type status created_at]
8
+ t.index %i[stateable_type stateable_id]
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ class CreateMetaStatesStates < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :meta_states_states do |t|
4
+ t.string :type, null: false
5
+ t.string :state_type
6
+ t.string :status, null: false
7
+
8
+ t.json :metadata, null: false, default: {}
9
+
10
+ t.references :stateable, polymorphic: true, null: false
11
+
12
+ t.timestamps
13
+
14
+ t.index %i[type stateable_id]
15
+ t.index %i[stateable_type stateable_id]
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ # spec/meta_states/callback_spec.rb
6
+ RSpec.describe MetaStates::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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spec/meta_states/configuration_spec.rb
4
+ require 'rails_helper'
5
+
6
+ RSpec.describe MetaStates::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(MetaStates::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
+
78
+ it 'allows setting a limit on the number of states' do
79
+ configuration.configure_model User do |model|
80
+ model.state_type :kyc do |type|
81
+ type.statuses = %w[pending completed]
82
+ type.limit = 2
83
+ end
84
+ end
85
+
86
+ expect(configuration.limit_for(User, 'kyc')).to eq(2)
87
+ end
88
+
89
+ it 'returns nil for limit when no limit is set' do
90
+ configuration.configure_model User do |model|
91
+ model.state_type :kyc do |type|
92
+ type.statuses = %w[pending completed]
93
+ end
94
+ end
95
+
96
+ expect(configuration.limit_for(User, 'kyc')).to be_nil
97
+ end
98
+
99
+ context 'when a metadata schema is set' do
100
+ let(:schema) do
101
+ {
102
+ type: :object,
103
+ properties: {
104
+ name: { type: :string },
105
+ age: { type: :integer, minimum: 18 }
106
+ },
107
+ required: %i[name age]
108
+ }
109
+ end
110
+
111
+ before do
112
+ configuration.configure_model User do |model|
113
+ model.state_type :kyc do |type|
114
+ type.statuses = %w[pending completed]
115
+ type.metadata_schema = schema
116
+ end
117
+ end
118
+ end
119
+
120
+ it 'allows setting a metadata schema for validation' do
121
+ expect(configuration.metadata_schema_for(User, 'kyc')).to eq(schema)
122
+ end
123
+ end
124
+
125
+ it 'returns nil for metadata_schema when no schema is set' do
126
+ configuration.configure_model User do |model|
127
+ model.state_type :kyc do |type|
128
+ type.statuses = %w[pending completed]
129
+ end
130
+ end
131
+
132
+ expect(configuration.metadata_schema_for(User, 'kyc')).to be_nil
133
+ end
134
+ end
135
+
136
+ describe 'callbacks' do
137
+ let(:state_completed) { create(:state, state_type: 'kyc', status: 'completed') }
138
+
139
+ before do
140
+ configuration.configure_model User do |model|
141
+ model.state_type :kyc do |type|
142
+ type.statuses = %w[pending completed failed]
143
+ end
144
+ end
145
+
146
+ configuration.on(:kyc, id: :failed_callback, to: 'failed') { |_s| :kyc_failed }
147
+ configuration.on(:kyc, id: :pending_callback, to: 'pending') { |_s| :kyc_pending }
148
+ configuration.on(:kyc, id: :complete_callback, to: 'completed') { |_s| :kyc_completed }
149
+ end
150
+
151
+ describe '#on' do
152
+ it 'registers callbacks' do
153
+ expect(configuration.callbacks.size).to eq(3)
154
+ end
155
+
156
+ it 'matches callbacks for the right state' do
157
+ matching = configuration.matching_callbacks(state_completed)
158
+
159
+ expect(matching.length).to eq(1)
160
+ end
161
+
162
+ it 'registers callbacks with auto-generated ids' do
163
+ configuration.on(:kyc) { |_s| :some_action }
164
+ expect(configuration.callbacks.keys.last).to match(/\Acallback_\d+\z/)
165
+ end
166
+
167
+ it 'registers callbacks with custom ids' do
168
+ configuration.on(:kyc, id: :my_custom_callback) { |_s| :some_action }
169
+ expect(configuration.callbacks.keys).to include(:my_custom_callback)
170
+ end
171
+
172
+ it 'converts string ids to symbols' do
173
+ configuration.on(:kyc, id: 'my_string_id') { |_s| :some_action }
174
+ expect(configuration.callbacks.keys).to include(:my_string_id)
175
+ end
176
+ end
177
+
178
+ describe '#off' do
179
+ it 'removes a callback by id' do
180
+ configuration.on(:kyc, id: :removable) { |_s| :some_action }
181
+ expect { configuration.off(:removable) }
182
+ .to change { configuration.callbacks.size }.by(-1)
183
+ end
184
+
185
+ it 'removes a callback by callback object' do
186
+ callback = configuration.on(:kyc) { |_s| :some_action }
187
+ expect { configuration.off(callback) }
188
+ .to change { configuration.callbacks.size }.by(-1)
189
+ end
190
+
191
+ it 'properly removes callback when given a callback object' do
192
+ callback = configuration.on(:kyc, id: :test) { |_s| :some_action }
193
+ expect(configuration.callbacks.values).to include(callback)
194
+
195
+ configuration.off(callback)
196
+ expect(configuration.callbacks.values).not_to include(callback)
197
+ end
198
+ end
199
+
200
+ describe '#clear_callbacks!' do
201
+ it 'removes all callbacks' do
202
+ expect { configuration.clear_callbacks! }.to change { configuration.callbacks.size }.to(0)
203
+ end
204
+ end
205
+
206
+ describe '#matching_callbacks' do
207
+ it 'finds callbacks matching state type and conditions regardless of id' do
208
+ configuration.clear_callbacks!
209
+ configuration.on(:kyc, id: :complete_callback, to: 'completed') { |_s| :kyc_completed }
210
+
211
+ matching = configuration.matching_callbacks(state_completed)
212
+
213
+ expect(matching.size).to eq(1)
214
+ expect(matching.first.call(state_completed)).to eq(:kyc_completed)
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe 'State Limit Feature', type: :model do
6
+ before do
7
+ MetaStates.configuration.clear_callbacks!
8
+ MetaStates.configuration.model_configurations.clear
9
+
10
+ MetaStates.configure do |config|
11
+ config.configure_model User do |model|
12
+ model.state_type :double_limit_state do |type|
13
+ type.statuses = %w[pending completed]
14
+ type.limit = 2
15
+ end
16
+
17
+ model.state_type :no_limit_state do |type|
18
+ type.statuses = %w[pending completed]
19
+ # No limit set for this type
20
+ end
21
+
22
+ model.state_type :single_limit_state do |type|
23
+ type.statuses = %w[pending completed]
24
+ type.limit = 1
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ describe 'state limit validation' do
31
+ let(:user) { create(:user) }
32
+
33
+ context 'with no limit set' do
34
+ it 'allows creating multiple states of the same type' do
35
+ # Create multiple onboarding states (no limit)
36
+ expect do
37
+ 3.times { user.add_state('no_limit_state', status: 'pending') }
38
+ end.not_to raise_error
39
+
40
+ expect(user.states.where(state_type: 'no_limit_state').count).to eq(3)
41
+ end
42
+ end
43
+
44
+ context 'with a limit set' do
45
+ it 'allows creating states up to the limit' do
46
+ # Create double_limit_state states up to the limit (2)
47
+ expect do
48
+ 2.times { user.add_state('double_limit_state', status: 'pending') }
49
+ end.not_to raise_error
50
+
51
+ expect(user.states.where(state_type: 'double_limit_state').count).to eq(2)
52
+ end
53
+
54
+ it 'prevents creating states beyond the limit' do
55
+ # Create double_limit_state states up to the limit
56
+ 2.times { user.add_state('double_limit_state', status: 'pending') }
57
+
58
+ # Try to create one more (exceeding the limit)
59
+ expect do
60
+ user.add_state('double_limit_state', status: 'pending')
61
+ end.to raise_error(ActiveRecord::RecordInvalid, /maximum number of double_limit_state states/)
62
+ end
63
+
64
+ it 'enforces a limit of 1 correctly' do
65
+ # Create one state
66
+ user.add_state('single_limit_state', status: 'pending')
67
+
68
+ # Try to create another one (exceeding the limit of 1)
69
+ expect do
70
+ user.add_state('single_limit_state', status: 'completed')
71
+ end.to raise_error(ActiveRecord::RecordInvalid, /maximum number of single_limit_state states/)
72
+ end
73
+ end
74
+
75
+ context 'with different state types' do
76
+ it 'applies limits independently to each state type' do
77
+ # Create states up to the limit for double_limit_state
78
+ 2.times { user.add_state('double_limit_state', status: 'pending') }
79
+
80
+ # Create states for no_limit_state (no limit)
81
+ 3.times { user.add_state('no_limit_state', status: 'pending') }
82
+
83
+ # Create one state for single_limit_state (limit 1)
84
+ user.add_state('single_limit_state', status: 'pending')
85
+
86
+ # Verify counts
87
+ expect(user.states.where(state_type: 'double_limit_state').count).to eq(2)
88
+ expect(user.states.where(state_type: 'no_limit_state').count).to eq(3)
89
+ expect(user.states.where(state_type: 'single_limit_state').count).to eq(1)
90
+
91
+ # Try to exceed limits
92
+ expect do
93
+ user.add_state('double_limit_state', status: 'completed')
94
+ end.to raise_error(ActiveRecord::RecordInvalid, /maximum number of double_limit_state states/)
95
+
96
+ expect do
97
+ user.add_state('single_limit_state', status: 'completed')
98
+ end.to raise_error(ActiveRecord::RecordInvalid, /maximum number of single_limit_state states/)
99
+
100
+ # But can still add more no_limit_state states
101
+ expect do
102
+ user.add_state('no_limit_state', status: 'completed')
103
+ end.not_to raise_error
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe 'Metadata Schema Validation', type: :model do
6
+ let(:user) { create(:user) }
7
+ let(:schema) do
8
+ {
9
+ type: :object,
10
+ properties: {
11
+ name: { type: :string },
12
+ age: {
13
+ type: :integer,
14
+ minimum: 18
15
+ }
16
+ },
17
+ required: %i[name age]
18
+ }
19
+ end
20
+
21
+ before do
22
+ MetaStates.configuration.clear_callbacks!
23
+ MetaStates.configuration.model_configurations.clear
24
+
25
+ MetaStates.configure do |config|
26
+ config.configure_model User do |model|
27
+ model.state_type :schema_enabled_state do |type|
28
+ type.statuses = %w[pending completed rejected]
29
+ type.metadata_schema = schema
30
+ end
31
+
32
+ model.state_type :no_schema_state do |type|
33
+ type.statuses = %w[active inactive]
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ describe 'validation' do
40
+ context 'with a valid schema' do
41
+ it 'validates valid metadata' do
42
+ # Invalid metadata (too young < 18)
43
+ expect do
44
+ user.add_state('schema_enabled_state', status: 'pending', metadata: { name: 'John Doe', age: 17 })
45
+ end.to raise_error(ActiveRecord::RecordInvalid, /did not have a minimum value of 18/)
46
+
47
+ # Invalid metadata (missing required fields)
48
+ expect do
49
+ user.add_state('schema_enabled_state', status: 'pending', metadata: { age: 25 })
50
+ end.to raise_error(ActiveRecord::RecordInvalid, /did not contain a required property of 'name'/)
51
+
52
+ # Valid metadata
53
+ state = user.add_state('schema_enabled_state', status: 'pending', metadata: { name: 'John Doe', age: 25 })
54
+
55
+ expect(state).to be_valid
56
+ expect(state.persisted?).to be true
57
+ end
58
+ end
59
+
60
+ context 'with no schema defined' do
61
+ it 'accepts any metadata for state types without a schema' do
62
+ state = user.add_state('no_schema_state', status: 'active', metadata: {
63
+ anything: 'goes',
64
+ nested: {
65
+ data: 'is fine too'
66
+ },
67
+ numbers: [1, 2, 3]
68
+ })
69
+
70
+ expect(state).to be_valid
71
+ expect(state.persisted?).to be true
72
+ end
73
+ end
74
+ end
75
+ end