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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +255 -0
- data/lib/generators/meta_states/install/install_generator.rb +43 -0
- data/lib/generators/meta_states/install/templates/create_indexes_on_meta_states_states.rb.erb +11 -0
- data/lib/generators/meta_states/install/templates/create_meta_states_states.rb.erb +18 -0
- data/lib/generators/meta_states/install/templates/initializer.rb.erb +42 -0
- data/lib/meta_states/base.rb +70 -0
- data/lib/meta_states/callback.rb +58 -0
- data/lib/meta_states/configuration/model_configuration.rb +64 -0
- data/lib/meta_states/configuration/state_type_configuration.rb +17 -0
- data/lib/meta_states/configuration.rb +142 -0
- data/lib/meta_states/railtie.rb +8 -0
- data/lib/meta_states/state.rb +5 -0
- data/lib/meta_states/stateable.rb +26 -0
- data/lib/meta_states/version.rb +5 -0
- data/lib/meta_states.rb +25 -0
- data/spec/dummy/Gemfile +40 -0
- data/spec/dummy/Gemfile.lock +308 -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/initializers/meta_states.rb +45 -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/20241223212128_create_has_states_states.rb +18 -0
- data/spec/dummy/db/migrate/20250114175939_create_indexes_on_has_states_states.rb +10 -0
- data/spec/dummy/db/schema.rb +44 -0
- data/spec/dummy/db/seeds.rb +11 -0
- data/spec/dummy/log/development.log +416 -0
- data/spec/dummy/log/test.log +55143 -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/meta_states.rb +19 -0
- data/spec/generators/meta_states/install_generator_spec.rb +27 -0
- data/spec/generators/templates/config/initializers/meta_states.rb +42 -0
- data/spec/generators/templates/db/migrate/20250605170637_create_indexes_on_meta_states_states.rb +11 -0
- data/spec/generators/templates/db/migrate/20250605170637_create_meta_states_states.rb +18 -0
- data/spec/meta_states/callback_spec.rb +92 -0
- data/spec/meta_states/configuration_spec.rb +218 -0
- data/spec/meta_states/state_limit_spec.rb +107 -0
- data/spec/meta_states/state_metadata_schema_spec.rb +75 -0
- data/spec/meta_states/state_spec.rb +349 -0
- data/spec/meta_states/stateable_spec.rb +183 -0
- data/spec/meta_states_spec.rb +52 -0
- data/spec/rails_helper.rb +19 -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 +141 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe MetaStates::State, type: :model do
|
|
6
|
+
subject { build(:state) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
MetaStates.configuration.clear_callbacks!
|
|
10
|
+
MetaStates.configuration.model_configurations.clear
|
|
11
|
+
|
|
12
|
+
MetaStates.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 'indexes' do
|
|
26
|
+
it 'defines an index on stateable_type and stateable_id' do
|
|
27
|
+
expect(subject).to have_db_index(%i[stateable_type stateable_id])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'defines an index on stateable_id and state_type' do
|
|
31
|
+
expect(subject).to have_db_index(%i[stateable_id state_type])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'defines an index on stateable_id, state_type, and status' do
|
|
35
|
+
expect(subject).to have_db_index(%i[stateable_id state_type status])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'defines an index on stateable_id, state_type, and created_at' do
|
|
39
|
+
expect(subject).to have_db_index(%i[stateable_id state_type created_at])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'defines an index on stateable_id, state_type, status, and created_at' do
|
|
43
|
+
expect(subject).to have_db_index(%i[stateable_id state_type status created_at])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe 'validations' do
|
|
48
|
+
context 'status' do
|
|
49
|
+
it 'validates status presence' do
|
|
50
|
+
expect(subject).not_to allow_value(nil).for(:status)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'validates status is configured' do
|
|
54
|
+
expect(subject).to allow_value('pending').for(:status)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'validates status that is not configured' do
|
|
58
|
+
expect(subject).not_to allow_value('invalid').for(:status)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
context 'state_type' do
|
|
63
|
+
it 'validates state_type presence' do
|
|
64
|
+
expect(subject).not_to allow_value(nil).for(:state_type)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'validates state_type is configured' do
|
|
68
|
+
expect(subject).to allow_value('kyc').for(:state_type)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'validates state_type that is not configured' do
|
|
72
|
+
expect(subject).not_to allow_value('invalid').for(:state_type)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe 'scopes' do
|
|
78
|
+
let(:state) { create(:state, state_type: 'kyc') }
|
|
79
|
+
|
|
80
|
+
it 'defines a scope for each state type' do
|
|
81
|
+
expect(described_class.kyc).to eq([state])
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe 'instance methods' do
|
|
86
|
+
let(:state) { create(:state, state_type: 'kyc', status: 'pending') }
|
|
87
|
+
let(:model_config) { MetaStates.configuration.model_configurations[User] }
|
|
88
|
+
let(:all_statuses) { model_config.state_types.values.flat_map(&:statuses).uniq }
|
|
89
|
+
|
|
90
|
+
context 'predicate methods' do
|
|
91
|
+
it 'defines predicate methods for all configured statuses' do
|
|
92
|
+
all_statuses.each do |status|
|
|
93
|
+
expect(state).to respond_to("#{status}?")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'returns true if the status is pending' do
|
|
98
|
+
expect(state.pending?).to be(true)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'returns false if the status is not completed' do
|
|
102
|
+
expect(state.completed?).to be(false)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
describe 'metadata' do
|
|
108
|
+
let(:user) { FactoryBot.create(:user) }
|
|
109
|
+
|
|
110
|
+
it 'stores and retrieves simple metadata' do
|
|
111
|
+
state = user.add_state('kyc', metadata: { reason: 'documents_missing' })
|
|
112
|
+
expect(state.metadata['reason']).to eq('documents_missing')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'stores and retrieves nested metadata' do
|
|
116
|
+
metadata = {
|
|
117
|
+
documents: {
|
|
118
|
+
passport: { status: 'rejected', reason: 'expired' },
|
|
119
|
+
utility_bill: { status: 'pending' }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
state = user.add_state('kyc', metadata: metadata)
|
|
124
|
+
expect(state.metadata['documents']['passport']['reason']).to eq('expired')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'handles arrays in metadata' do
|
|
128
|
+
metadata = {
|
|
129
|
+
missing_documents: %w[passport utility_bill],
|
|
130
|
+
review_history: [
|
|
131
|
+
{ date: '2024-01-01', status: 'rejected' },
|
|
132
|
+
{ date: '2024-01-02', status: 'approved' }
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
state = user.add_state('kyc', metadata: metadata)
|
|
137
|
+
expect(state.metadata['missing_documents']).to eq(%w[passport utility_bill])
|
|
138
|
+
expect(state.metadata['review_history'].size).to eq(2)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'handles different data types' do
|
|
142
|
+
metadata = {
|
|
143
|
+
string_value: 'test',
|
|
144
|
+
integer_value: 42,
|
|
145
|
+
float_value: 42.5,
|
|
146
|
+
boolean_value: true,
|
|
147
|
+
null_value: nil,
|
|
148
|
+
date_value: '2024-01-01'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
state = user.add_state('kyc', metadata: metadata)
|
|
152
|
+
expect(state.metadata['string_value']).to eq('test')
|
|
153
|
+
expect(state.metadata['integer_value']).to eq(42)
|
|
154
|
+
expect(state.metadata['float_value']).to eq(42.5)
|
|
155
|
+
expect(state.metadata['boolean_value']).to be true
|
|
156
|
+
expect(state.metadata['null_value']).to be_nil
|
|
157
|
+
expect(state.metadata['date_value']).to eq('2024-01-01')
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'defaults to empty hash when no metadata provided' do
|
|
161
|
+
state = user.add_state('kyc')
|
|
162
|
+
expect(state.metadata).to eq({})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'persists metadata across database reads' do
|
|
166
|
+
state = user.add_state('kyc', metadata: { key: 'value' })
|
|
167
|
+
reloaded_state = MetaStates::State.find(state.id)
|
|
168
|
+
expect(reloaded_state.metadata['key']).to eq('value')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe 'callbacks' do
|
|
173
|
+
let(:user) { create(:user) }
|
|
174
|
+
let(:callback_executed) { false }
|
|
175
|
+
|
|
176
|
+
before do
|
|
177
|
+
MetaStates.configure do |config|
|
|
178
|
+
config.configure_model User do |model|
|
|
179
|
+
model.state_type :onboarding do |type|
|
|
180
|
+
type.statuses = %w[pending completed]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Register a callback for when onboarding is completed
|
|
185
|
+
config.on(:onboarding, id: :complete_onboarding, to: 'completed') do |state|
|
|
186
|
+
state.stateable.update!(name: 'Onboarded User')
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'executes callback when state changes to completed' do
|
|
192
|
+
# Create initial pending state
|
|
193
|
+
state = user.add_state('onboarding', status: 'pending')
|
|
194
|
+
expect(user.name).not_to eq('Onboarded User')
|
|
195
|
+
|
|
196
|
+
# Update to completed
|
|
197
|
+
state.update!(status: 'completed')
|
|
198
|
+
|
|
199
|
+
# Verify callback was executed
|
|
200
|
+
expect(user.reload.name).to eq('Onboarded User')
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'does not execute callback for other status changes' do
|
|
204
|
+
state = user.add_state('onboarding', status: 'completed')
|
|
205
|
+
user.update!(name: 'Original Name')
|
|
206
|
+
|
|
207
|
+
# Update to pending
|
|
208
|
+
state.update!(status: 'pending')
|
|
209
|
+
|
|
210
|
+
# Verify callback was not executed
|
|
211
|
+
expect(user.reload.name).to eq('Original Name')
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
describe 'limited execution callbacks' do
|
|
216
|
+
let(:user) { create(:user) }
|
|
217
|
+
|
|
218
|
+
before do
|
|
219
|
+
# Clear ALL configuration
|
|
220
|
+
MetaStates.configuration.clear_callbacks!
|
|
221
|
+
MetaStates.configuration.model_configurations.clear
|
|
222
|
+
|
|
223
|
+
# Set up fresh configuration
|
|
224
|
+
MetaStates.configure do |config|
|
|
225
|
+
config.configure_model User do |model|
|
|
226
|
+
model.state_type :onboarding do |type|
|
|
227
|
+
type.statuses = %w[pending completed]
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
@execution_count = 0
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'executes callback only specified number of times' do
|
|
236
|
+
MetaStates.configure do |config|
|
|
237
|
+
config.on(:onboarding, id: :counter, to: 'completed', times: 2) do |state|
|
|
238
|
+
state.stateable.update!(name: "Execution #{@execution_count += 1}")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# First execution
|
|
243
|
+
state1 = user.add_state('onboarding', status: 'pending')
|
|
244
|
+
state1.update!(status: 'completed')
|
|
245
|
+
expect(user.reload.name).to eq('Execution 1')
|
|
246
|
+
|
|
247
|
+
# Second execution
|
|
248
|
+
state2 = user.add_state('onboarding', status: 'pending')
|
|
249
|
+
state2.update!(status: 'completed')
|
|
250
|
+
expect(user.reload.name).to eq('Execution 2')
|
|
251
|
+
|
|
252
|
+
# Third execution - callback should be expired
|
|
253
|
+
user.update!(name: 'Final Name')
|
|
254
|
+
state3 = user.add_state('onboarding', status: 'pending')
|
|
255
|
+
state3.update!(status: 'completed')
|
|
256
|
+
expect(user.reload.name).to eq('Final Name') # Name shouldn't change
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it 'keeps callback active indefinitely when times is not specified' do
|
|
260
|
+
MetaStates.configure do |config|
|
|
261
|
+
config.on(:onboarding, id: :infinite, to: 'completed') do |state|
|
|
262
|
+
state.stateable.update!(name: "Execution #{@execution_count += 1}")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
3.times do |i|
|
|
267
|
+
state = user.add_state('onboarding', status: 'pending')
|
|
268
|
+
state.update!(status: 'completed')
|
|
269
|
+
expect(user.reload.name).to eq("Execution #{i + 1}")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
it 'removes expired callbacks from configuration' do
|
|
274
|
+
MetaStates.configure do |config|
|
|
275
|
+
config.on(:onboarding, id: :one_time, to: 'completed', times: 1) do |state|
|
|
276
|
+
state.stateable.update!(name: 'Executed')
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
expect do
|
|
281
|
+
state = user.add_state('onboarding', status: 'pending')
|
|
282
|
+
state.update!(status: 'completed')
|
|
283
|
+
end.to change { MetaStates.configuration.callbacks.size }.from(1).to(0)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
describe 'custom state types' do
|
|
288
|
+
# Create a custom state class for testing
|
|
289
|
+
class KYCState < MetaStates::Base
|
|
290
|
+
validates :metadata, presence: true
|
|
291
|
+
validate :required_metadata_fields
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def required_metadata_fields
|
|
296
|
+
return if metadata&.key?('document_type')
|
|
297
|
+
|
|
298
|
+
errors.add(:metadata, 'must include document_type')
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
let(:user) { create(:user) }
|
|
303
|
+
|
|
304
|
+
it 'allows creation of custom state types' do
|
|
305
|
+
state = user.add_state(
|
|
306
|
+
'kyc',
|
|
307
|
+
status: 'pending',
|
|
308
|
+
metadata: { document_type: 'passport' },
|
|
309
|
+
state_class: KYCState
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
expect(state).to be_valid
|
|
313
|
+
expect(state).to be_a(KYCState)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it 'enforces custom validations' do
|
|
317
|
+
expect do
|
|
318
|
+
user.add_state(
|
|
319
|
+
'kyc',
|
|
320
|
+
status: 'pending',
|
|
321
|
+
metadata: { other_field: 'value' },
|
|
322
|
+
state_class: KYCState
|
|
323
|
+
)
|
|
324
|
+
end.to raise_error(
|
|
325
|
+
ActiveRecord::RecordInvalid,
|
|
326
|
+
/Metadata must include document_type/
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'defaults to MetaStates::State when no state_class specified' do
|
|
331
|
+
state = user.add_state('kyc', status: 'pending')
|
|
332
|
+
|
|
333
|
+
expect(state).to be_a(MetaStates::State)
|
|
334
|
+
expect(state).to be_valid
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it 'maintains STI type across database reads' do
|
|
338
|
+
state = user.add_state(
|
|
339
|
+
'kyc',
|
|
340
|
+
status: 'pending',
|
|
341
|
+
metadata: { document_type: 'passport' },
|
|
342
|
+
state_class: KYCState
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
reloaded_state = MetaStates::Base.find(state.id)
|
|
346
|
+
expect(reloaded_state).to be_a(KYCState)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe MetaStates::Stateable do
|
|
4
|
+
# Create a test class that includes the concern
|
|
5
|
+
let(:test_class) do
|
|
6
|
+
Class.new do
|
|
7
|
+
include MetaStates::Stateable
|
|
8
|
+
include ActiveRecord::Model
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
let(:user) { create(:user) } # Assuming you have a User factory
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
# MetaStates.configuration.clear_callbacks!
|
|
16
|
+
# MetaStates.configuration.model_configurations.clear
|
|
17
|
+
|
|
18
|
+
MetaStates.configure do |config|
|
|
19
|
+
config.configure_model User do |model|
|
|
20
|
+
model.state_type :test_type do |type|
|
|
21
|
+
type.statuses = %w[pending completed]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
model.state_type :other_test_type do |type|
|
|
25
|
+
type.statuses = %w[pending completed]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe 'associations' do
|
|
32
|
+
it 'has many states' do
|
|
33
|
+
association = User.reflect_on_association(:states)
|
|
34
|
+
|
|
35
|
+
expect(association.macro).to eq :has_many
|
|
36
|
+
expect(association.options[:as]).to eq :stateable
|
|
37
|
+
expect(association.options[:dependent]).to eq :destroy
|
|
38
|
+
expect(association.options[:class_name]).to eq 'MetaStates::Base'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#add_state' do
|
|
43
|
+
context 'with default parameters' do
|
|
44
|
+
it 'creates a new state with default values' do
|
|
45
|
+
state = user.add_state('test_type')
|
|
46
|
+
|
|
47
|
+
expect(state).to be_persisted
|
|
48
|
+
expect(state.metadata).to eq({})
|
|
49
|
+
expect(state.status).to eq 'pending'
|
|
50
|
+
expect(state.state_type).to eq 'test_type'
|
|
51
|
+
expect(state.type).to eq 'MetaStates::State'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
context 'with custom parameters' do
|
|
56
|
+
let(:metadata) { { key: 'value' } }
|
|
57
|
+
|
|
58
|
+
it 'creates a new state with provided values' do
|
|
59
|
+
state = user.add_state('test_type', status: 'completed', metadata: metadata)
|
|
60
|
+
|
|
61
|
+
expect(state).to be_persisted
|
|
62
|
+
expect(state.status).to eq 'completed'
|
|
63
|
+
expect(state.state_type).to eq 'test_type'
|
|
64
|
+
expect(state.type).to eq 'MetaStates::State'
|
|
65
|
+
expect(state.metadata).to eq metadata.as_json
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context 'with custom state class' do
|
|
70
|
+
before(:all) do
|
|
71
|
+
module MetaStates
|
|
72
|
+
class CustomState < State; end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
after(:all) do
|
|
77
|
+
MetaStates.send(:remove_const, :CustomState)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'creates a new state with the specified class' do
|
|
81
|
+
state = user.add_state('test_type', state_class: MetaStates::CustomState)
|
|
82
|
+
|
|
83
|
+
expect(state).to be_persisted
|
|
84
|
+
expect(state.type).to eq 'MetaStates::CustomState'
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context 'when validation fails' do
|
|
89
|
+
before do
|
|
90
|
+
allow_any_instance_of(MetaStates::Base).to receive(:save!).and_raise(ActiveRecord::RecordInvalid)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'raises an ActiveRecord::RecordInvalid error' do
|
|
94
|
+
expect { user.add_state('test_type') }.to raise_error(ActiveRecord::RecordInvalid)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe '#current_state' do
|
|
100
|
+
let!(:latest_state) { user.add_state('test_type', status: 'completed') }
|
|
101
|
+
|
|
102
|
+
before do
|
|
103
|
+
travel_to 1.day.ago do
|
|
104
|
+
3.times { user.add_state('test_type', status: 'pending') }
|
|
105
|
+
2.times { user.add_state('other_test_type', status: 'pending') }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'returns the most recent state for the given type' do
|
|
110
|
+
expect(user.current_state('test_type')).to eq latest_state
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns nil when no state exists for the given type' do
|
|
114
|
+
expect(user.current_state('nonexistent')).to be_nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe '#current_states' do
|
|
119
|
+
before do
|
|
120
|
+
3.times { user.add_state('test_type', status: 'pending') }
|
|
121
|
+
3.times { user.add_state('other_test_type', status: 'completed') }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'returns all states for the given type ordered by creation time' do
|
|
125
|
+
test_types = user.current_states('test_type')
|
|
126
|
+
|
|
127
|
+
expect(test_types.size).to eq 3
|
|
128
|
+
expect(test_types).to eq test_types.sort_by(&:created_at).reverse
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'returns empty relation when no states exist for the given type' do
|
|
132
|
+
nonexistent_states = user.current_states('nonexistent')
|
|
133
|
+
expect(nonexistent_states).to be_empty
|
|
134
|
+
expect(nonexistent_states).to be_a(ActiveRecord::Relation)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe 'query methods' do
|
|
139
|
+
before do
|
|
140
|
+
2.times { user.add_state('test_type', status: 'pending') }
|
|
141
|
+
2.times { user.add_state('other_test_type', status: 'pending') }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
context 'find one methods' do
|
|
145
|
+
it 'defines query one methods for configured states' do
|
|
146
|
+
expect(user).to respond_to('test_type')
|
|
147
|
+
expect(user).to respond_to('other_test_type')
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'returns the most recent state for the given status' do
|
|
151
|
+
expect(user.test_type).to eq user.current_state('test_type')
|
|
152
|
+
expect(user.other_test_type).to eq user.current_state('other_test_type')
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
context 'find many methods' do
|
|
157
|
+
it 'defines query all methods for configured states' do
|
|
158
|
+
expect(user).to respond_to('test_types')
|
|
159
|
+
expect(user).to respond_to('other_test_types')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'returns all states for the given status ordered by creation time' do
|
|
163
|
+
test_types = user.test_types
|
|
164
|
+
other_test_types = user.other_test_types
|
|
165
|
+
|
|
166
|
+
expect(test_types.size).to eq 2
|
|
167
|
+
expect(other_test_types.size).to eq 2
|
|
168
|
+
|
|
169
|
+
expect(test_types).to eq test_types.sort_by(&:created_at).reverse
|
|
170
|
+
expect(other_test_types).to eq other_test_types.sort_by(&:created_at).reverse
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Optional: Test the destruction of associated states
|
|
176
|
+
describe 'destroying the stateable object' do
|
|
177
|
+
let!(:state) { user.add_state('test_type') }
|
|
178
|
+
|
|
179
|
+
it 'destroys associated states when the user is destroyed' do
|
|
180
|
+
expect { user.destroy }.to change { MetaStates::Base.count }.by(-1)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe MetaStates do
|
|
4
|
+
it 'has a version number' do
|
|
5
|
+
expect(MetaStates::VERSION).not_to be_nil
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'has a configuration' do
|
|
9
|
+
expect(described_class.configuration).to be_a(MetaStates::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
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Load dummy Rails app if in Rails context
|
|
6
|
+
ENV['RAILS_ENV'] = 'test'
|
|
7
|
+
require File.expand_path('dummy/config/environment.rb', __dir__)
|
|
8
|
+
|
|
9
|
+
# Load support files
|
|
10
|
+
require_relative 'support/factory_bot'
|
|
11
|
+
require_relative 'support/shoulda_matchers'
|
|
12
|
+
require_relative 'support/database_cleaner'
|
|
13
|
+
|
|
14
|
+
RSpec.configure do |config|
|
|
15
|
+
config.include(Shoulda::Matchers::ActiveModel, type: :model)
|
|
16
|
+
config.include(Shoulda::Matchers::ActiveRecord, type: :model)
|
|
17
|
+
config.include(ActiveSupport::Testing::TimeHelpers)
|
|
18
|
+
# ... rest of your RSpec configuration
|
|
19
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'meta_states'
|
|
5
|
+
|
|
6
|
+
RSpec.configure do |config|
|
|
7
|
+
# Enable flags like --only-failures and --next-failure
|
|
8
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
9
|
+
|
|
10
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
|
11
|
+
config.disable_monkey_patching!
|
|
12
|
+
|
|
13
|
+
config.expect_with :rspec do |c|
|
|
14
|
+
c.syntax = :expect
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'database_cleaner/active_record'
|
|
4
|
+
|
|
5
|
+
RSpec.configure do |config|
|
|
6
|
+
# Database Cleaner setup
|
|
7
|
+
config.before(:suite) do
|
|
8
|
+
DatabaseCleaner.strategy = :transaction
|
|
9
|
+
DatabaseCleaner.clean_with(:truncation)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
config.around do |example|
|
|
13
|
+
DatabaseCleaner.cleaning do
|
|
14
|
+
example.run
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
MetaStates.configure do |config|
|
|
4
|
+
# Configure your models here
|
|
5
|
+
# Example:
|
|
6
|
+
#
|
|
7
|
+
# config.configure_model User do |model|
|
|
8
|
+
# model.state_type :kyc do |type|
|
|
9
|
+
# type.statuses = ['pending', 'completed', 'rejected']
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateMetaStatesStates < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :meta_states_states do |t|
|
|
6
|
+
t.string :state_type
|
|
7
|
+
t.string :status, null: false
|
|
8
|
+
|
|
9
|
+
t.json :metadata, null: false, default: {}
|
|
10
|
+
|
|
11
|
+
t.references :stateable, polymorphic: true, null: false
|
|
12
|
+
|
|
13
|
+
t.datetime :completed_at
|
|
14
|
+
t.timestamps
|
|
15
|
+
|
|
16
|
+
t.index [:status]
|
|
17
|
+
t.index %i[stateable_type stateable_id]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|