smash_the_state 1.2.4
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/README.md +422 -0
- data/Rakefile +21 -0
- data/lib/smash_the_state.rb +7 -0
- data/lib/smash_the_state/matchers.rb +56 -0
- data/lib/smash_the_state/operation.rb +111 -0
- data/lib/smash_the_state/operation/definition.rb +37 -0
- data/lib/smash_the_state/operation/dry_run.rb +75 -0
- data/lib/smash_the_state/operation/error.rb +19 -0
- data/lib/smash_the_state/operation/sequence.rb +116 -0
- data/lib/smash_the_state/operation/state.rb +104 -0
- data/lib/smash_the_state/operation/state_type.rb +19 -0
- data/lib/smash_the_state/operation/step.rb +21 -0
- data/lib/smash_the_state/version.rb +3 -0
- data/spec/unit/operation/definition_spec.rb +57 -0
- data/spec/unit/operation/sequence_spec.rb +256 -0
- data/spec/unit/operation/state_spec.rb +143 -0
- data/spec/unit/operation/step_spec.rb +30 -0
- data/spec/unit/operation_spec.rb +493 -0
- metadata +90 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe SmashTheState::Operation::Step do
|
4
|
+
let!(:subject) { SmashTheState::Operation::Step }
|
5
|
+
let!(:implementation) { proc {} }
|
6
|
+
let!(:options) { { foo: :bar, side_effect_free: true } }
|
7
|
+
|
8
|
+
describe "#initialize" do
|
9
|
+
it "sets the name, implementation, and default options" do
|
10
|
+
instance = subject.new(:conquest, options, &implementation)
|
11
|
+
expect(instance.implementation).to eq(implementation)
|
12
|
+
expect(instance.name).to eq(:conquest)
|
13
|
+
expect(instance.options[:foo]).to eq(:bar)
|
14
|
+
expect(instance.options[:side_effect_free]).to eq(true)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "#side_effect_free?" do
|
19
|
+
let!(:instance) { subject.new(:conquest, {}, &implementation) }
|
20
|
+
|
21
|
+
it "defaults to false" do
|
22
|
+
expect(instance.side_effect_free?).to eq(false)
|
23
|
+
end
|
24
|
+
|
25
|
+
it "can be set to true" do
|
26
|
+
instance.options[:side_effect_free] = true
|
27
|
+
expect(instance.side_effect_free?).to eq(true)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,493 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe SmashTheState::Operation do
|
4
|
+
let!(:klass) do
|
5
|
+
Class.new(SmashTheState::Operation).tap do |k|
|
6
|
+
k.class_eval do
|
7
|
+
schema do
|
8
|
+
attribute :name, :string
|
9
|
+
attribute :age, :integer
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "#self.call" do
|
16
|
+
let!(:sequence) { klass.send(:sequence) }
|
17
|
+
|
18
|
+
it "passes a new state class instance to the sequence, returning the result" do
|
19
|
+
expect(sequence).to receive(:call) do |state|
|
20
|
+
@state = state
|
21
|
+
:result
|
22
|
+
end
|
23
|
+
|
24
|
+
result = klass.call({})
|
25
|
+
expect(result).to eq(:result)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "with no defined schema" do
|
30
|
+
let!(:params) { double }
|
31
|
+
let!(:klass) do
|
32
|
+
Class.new(SmashTheState::Operation)
|
33
|
+
end
|
34
|
+
|
35
|
+
let!(:sequence) { klass.send(:sequence) }
|
36
|
+
|
37
|
+
it "passes in the raw params as the initial state" do
|
38
|
+
expect(sequence).to receive(:call).with(params)
|
39
|
+
klass.call(params)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe "#self.schema" do
|
44
|
+
before do
|
45
|
+
klass.schema do
|
46
|
+
attribute :food, :string
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
it "sets the state_class an Operation::State-derived class with the evaluated block" do
|
51
|
+
expect(klass.state_class.attributes_registry).to eq(food: [:string, {}])
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "#self.step" do
|
56
|
+
before do
|
57
|
+
klass.step :first_name, community: true do |state|
|
58
|
+
state.tap do
|
59
|
+
state.name = "Emma"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
klass.step :last_name do |state|
|
64
|
+
state.tap do
|
65
|
+
state.name.concat(" Goldman")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
it "adds a step that is handed the previous state and hands the return " \
|
71
|
+
"value to the next step" do
|
72
|
+
state = klass.call(age: 148)
|
73
|
+
expect(state.name).to eq("Emma Goldman")
|
74
|
+
expect(state.age).to eq(148)
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "original state" do
|
78
|
+
before do
|
79
|
+
klass.step :change_state_to_something_else1 do |_state|
|
80
|
+
:something_else
|
81
|
+
end
|
82
|
+
|
83
|
+
klass.step :change_state_to_something_else2 do |state, original_state|
|
84
|
+
# state should be changed by the previous step
|
85
|
+
raise "should not hit this" unless state == :something_else
|
86
|
+
|
87
|
+
# and switch the state back to the original state, which should be
|
88
|
+
# available via the second argument to the block
|
89
|
+
original_state
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
it "is always available in each step as the second argument passed " \
|
94
|
+
"into the step block" do
|
95
|
+
state = klass.call(age: 148)
|
96
|
+
expect(state.name).to eq(nil)
|
97
|
+
expect(state.age).to eq(148)
|
98
|
+
end
|
99
|
+
|
100
|
+
it "sets the options on the step" do
|
101
|
+
expect(klass.sequence.steps.first.options[:community]).to eq(true)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
describe "self#dry_run_sequence" do
|
107
|
+
context "with a custom dry run sequence block" do
|
108
|
+
before do
|
109
|
+
klass.step :step_one do |state|
|
110
|
+
state.name = state.name + " one"
|
111
|
+
state
|
112
|
+
end
|
113
|
+
|
114
|
+
klass.step :step_two do |state|
|
115
|
+
state.name = state.name + " two"
|
116
|
+
state
|
117
|
+
end
|
118
|
+
|
119
|
+
klass.step :step_three do |state|
|
120
|
+
state.name = state.name + " three"
|
121
|
+
state
|
122
|
+
end
|
123
|
+
|
124
|
+
klass.dry_run_sequence do
|
125
|
+
# we'll reference this step
|
126
|
+
step :step_one
|
127
|
+
|
128
|
+
# we'll provide a custom implementation of this step
|
129
|
+
step :step_two do |state|
|
130
|
+
state.name = state.name + " custom"
|
131
|
+
state
|
132
|
+
end
|
133
|
+
|
134
|
+
# and step three will be just be omitted
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
it "provides a custom sequence for the dry run that " \
|
139
|
+
"contains only side-effect free steps" do
|
140
|
+
result = klass.call(name: "Sam")
|
141
|
+
expect(result.name).to eq("Sam one two three")
|
142
|
+
|
143
|
+
expect(
|
144
|
+
klass.dry_run_sequence.steps.all?(&:side_effect_free?)
|
145
|
+
).to eq(true)
|
146
|
+
|
147
|
+
dry_result = klass.dry_run(name: "Sam")
|
148
|
+
expect(dry_result.name).to eq("Sam one custom")
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
describe "self#error" do
|
154
|
+
before do
|
155
|
+
klass.class_eval do |k|
|
156
|
+
k.step :what_about_roads do |state|
|
157
|
+
if state.name == "broken roads"
|
158
|
+
error!(state)
|
159
|
+
else
|
160
|
+
state
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
klass.error :what_about_roads do |state|
|
166
|
+
state.tap do
|
167
|
+
state.name = "working roads"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
it "adds an error handler to the specified step(s)" do
|
173
|
+
state = klass.call(name: "broken roads")
|
174
|
+
expect(state.name).to eq("working roads")
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
describe "self#policy" do
|
179
|
+
let!(:current_user) { Struct.new(:age).new(64) }
|
180
|
+
|
181
|
+
before do
|
182
|
+
policy_klass = Class.new.tap do |k|
|
183
|
+
k.class_eval do
|
184
|
+
attr_reader :user, :state
|
185
|
+
|
186
|
+
def initialize(user, state)
|
187
|
+
@user = user
|
188
|
+
@state = state
|
189
|
+
end
|
190
|
+
|
191
|
+
def allowed?
|
192
|
+
@user.age > 21
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
klass.class_eval do
|
198
|
+
policy policy_klass, :allowed?
|
199
|
+
|
200
|
+
# we should receive the state from the policy test
|
201
|
+
step :was_allowed do |state|
|
202
|
+
state.tap do
|
203
|
+
state.name = "allowed"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
@policy_klass = policy_klass
|
209
|
+
end
|
210
|
+
|
211
|
+
context "when the policy permits" do
|
212
|
+
it "newifies the policy class with the state, runs the method" do
|
213
|
+
state = klass.call(current_user: current_user)
|
214
|
+
expect(state.name).to eq("allowed")
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
context "when the policy forbids" do
|
219
|
+
before do
|
220
|
+
current_user.age = 3
|
221
|
+
end
|
222
|
+
|
223
|
+
it "raises an exception, embeds the policy instance" do
|
224
|
+
begin
|
225
|
+
klass.call(current_user: current_user)
|
226
|
+
raise "should not hit this"
|
227
|
+
rescue SmashTheState::Operation::NotAuthorized => e
|
228
|
+
expect(e.policy_instance).to be_a(@policy_klass)
|
229
|
+
expect(e.policy_instance.user).to eq(current_user)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
describe "self#middleware_class" do
|
236
|
+
let!(:sequence) { klass.send(:sequence) }
|
237
|
+
|
238
|
+
before do
|
239
|
+
klass.middleware_class do |state|
|
240
|
+
"#{state.name.camelize}::Class"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
it "sets the middleware class block on the sequence" do
|
245
|
+
string_class = sequence.middleware_class_block.call(
|
246
|
+
Struct.new(:name).new("working")
|
247
|
+
)
|
248
|
+
|
249
|
+
expect(string_class).to eq("Working::Class")
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
describe "self#middleware_step" do
|
254
|
+
let!(:sequence) { klass.send(:sequence) }
|
255
|
+
let!(:step_name) { :means_of_production }
|
256
|
+
let!(:step_options) { { foo: :bar } }
|
257
|
+
|
258
|
+
it "delegates to sequence#add_middleware_step" do
|
259
|
+
expect(sequence).to receive(:add_middleware_step).with(step_name, step_options)
|
260
|
+
klass.middleware_step :means_of_production, step_options
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
describe "#validate" do
|
265
|
+
before do
|
266
|
+
klass.validate do |_state|
|
267
|
+
validates_presence_of :name
|
268
|
+
end
|
269
|
+
|
270
|
+
klass.step :skip_this do |_state|
|
271
|
+
raise "should not hit this"
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
it "adds a validation step with the specified block, marked as " \
|
276
|
+
"side-effect free, that skips steps with side-effects" do
|
277
|
+
state = klass.call(name: nil)
|
278
|
+
expect(state.errors[:name]).to include("can't be blank")
|
279
|
+
expect(
|
280
|
+
klass.sequence.steps_for_name(:validate).all?(&:side_effect_free?)
|
281
|
+
).to be true
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
describe "#custom_validation" do
|
286
|
+
before do
|
287
|
+
klass.custom_validation do |state|
|
288
|
+
state.errors.add(:name, "no gods") if state.name == "zeus"
|
289
|
+
end
|
290
|
+
|
291
|
+
klass.step :skip_this do |_state|
|
292
|
+
raise "should not hit this"
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
it "adds a custom validation step with the specified block, skips subsequent steps" do
|
297
|
+
state = klass.call(name: "zeus")
|
298
|
+
expect(state.errors[:name]).to include("no gods")
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
describe "#dry_run" do
|
303
|
+
context "with a validation step" do
|
304
|
+
before do
|
305
|
+
klass.step :run_this do |state|
|
306
|
+
state.name = state.name + " People"
|
307
|
+
state
|
308
|
+
end
|
309
|
+
|
310
|
+
klass.validate do |_state|
|
311
|
+
validates_presence_of :name
|
312
|
+
validates_presence_of :age
|
313
|
+
end
|
314
|
+
|
315
|
+
klass.step :skip_this do |_state|
|
316
|
+
raise "should not hit this"
|
317
|
+
end
|
318
|
+
|
319
|
+
klass.step :safe, side_effect_free: true do |state|
|
320
|
+
state.name = state.name + " are nice"
|
321
|
+
state
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
it "runs all the steps up to and including validation, plus any " \
|
326
|
+
"further steps marked side-effect free" do
|
327
|
+
result = klass.dry_run(name: "Snake")
|
328
|
+
expect(result.name).to eq("Snake People")
|
329
|
+
expect(result.errors[:name]).to be_empty
|
330
|
+
expect(result.errors[:age]).to eq(["can't be blank"])
|
331
|
+
|
332
|
+
result = klass.dry_run(name: "Snake", age: 35)
|
333
|
+
expect(result.name).to eq("Snake People are nice")
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
context "with no validation step" do
|
338
|
+
before do
|
339
|
+
klass.step :run_this, side_effect_free: true do |state|
|
340
|
+
state.name = state.name + " People"
|
341
|
+
state
|
342
|
+
end
|
343
|
+
|
344
|
+
klass.step :skip_this do |_state|
|
345
|
+
raise "should not hit this"
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
it "returns the state produced by the side-effect free steps" do
|
350
|
+
result = klass.dry_call(name: "Snake")
|
351
|
+
expect(result.name).to eq("Snake People")
|
352
|
+
expect(result.errors).to be_empty
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
describe "#represent" do
|
358
|
+
let!(:representer) do
|
359
|
+
Class.new.tap do |k|
|
360
|
+
k.class_eval do
|
361
|
+
attr_reader :state
|
362
|
+
|
363
|
+
def self.represent(state)
|
364
|
+
new(state)
|
365
|
+
end
|
366
|
+
|
367
|
+
def initialize(state)
|
368
|
+
@state = state
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
|
374
|
+
let!(:params) { { name: "zeus" } }
|
375
|
+
|
376
|
+
before do
|
377
|
+
klass.represent representer
|
378
|
+
end
|
379
|
+
|
380
|
+
it "adds a representer step, marked as side-effect free, which returns a " \
|
381
|
+
"representer initialized with the state" do
|
382
|
+
expect(representer).to receive(:represent).and_call_original
|
383
|
+
represented = klass.call(params)
|
384
|
+
expect(represented).to be_a(representer)
|
385
|
+
expect(represented.state.name).to eq("zeus")
|
386
|
+
|
387
|
+
step = klass.sequence.steps.find { |s| s.name == :represent }
|
388
|
+
expect(step.side_effect_free?).to eq(true)
|
389
|
+
end
|
390
|
+
|
391
|
+
describe "represent_with spec helper" do
|
392
|
+
it "matches" do
|
393
|
+
allow_any_instance_of(String).to receive(:as_json) do |string|
|
394
|
+
string
|
395
|
+
end
|
396
|
+
|
397
|
+
expect(klass).to represent_with representer
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
describe "represent_collection_with spec helper" do
|
402
|
+
it "matches" do
|
403
|
+
allow_any_instance_of(String).to receive(:as_json) do |string|
|
404
|
+
string
|
405
|
+
end
|
406
|
+
|
407
|
+
expect(klass).to represent_collection_with representer
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
describe "represent_collection spec helper" do
|
412
|
+
it "matches" do
|
413
|
+
allow_any_instance_of(String).to receive(:as_json) do |string|
|
414
|
+
{ string => string }
|
415
|
+
end
|
416
|
+
|
417
|
+
expect(klass).to represent_collection "representation", with: representer
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
describe "#continues_from" do
|
423
|
+
context "with a state class" do
|
424
|
+
let!(:continuing_operation) do
|
425
|
+
Class.new(SmashTheState::Operation).tap do |k1|
|
426
|
+
k1.class_eval do
|
427
|
+
@prelude_klass = Class.new(SmashTheState::Operation).tap do |k2|
|
428
|
+
k2.class_eval do
|
429
|
+
schema do
|
430
|
+
attribute :name, :string
|
431
|
+
attribute :age, :integer
|
432
|
+
end
|
433
|
+
|
434
|
+
step :prelude_step do |state|
|
435
|
+
state.name = "Peter"
|
436
|
+
state
|
437
|
+
end
|
438
|
+
|
439
|
+
dry_run_sequence do
|
440
|
+
step :prelude_step
|
441
|
+
end
|
442
|
+
end
|
443
|
+
end
|
444
|
+
|
445
|
+
continues_from @prelude_klass
|
446
|
+
|
447
|
+
step :extra_step do |state|
|
448
|
+
state.age = 166
|
449
|
+
state
|
450
|
+
end
|
451
|
+
|
452
|
+
dry_run_sequence do
|
453
|
+
step :extra_step
|
454
|
+
end
|
455
|
+
end
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
let!(:prelude) do
|
460
|
+
continuing_operation.instance_variable_get(:@prelude_klass)
|
461
|
+
end
|
462
|
+
|
463
|
+
it "continues from the prelude operation" do
|
464
|
+
result = continuing_operation.call({})
|
465
|
+
expect(result.name).to eq("Peter")
|
466
|
+
expect(result.age).to eq(166)
|
467
|
+
|
468
|
+
steps = continuing_operation.dry_run_sequence.steps.map(&:implementation)
|
469
|
+
prelude_steps = prelude.dry_run_sequence.steps.map(&:implementation)
|
470
|
+
expect(steps & prelude_steps).to eq(prelude_steps)
|
471
|
+
end
|
472
|
+
|
473
|
+
describe "continues_from spec helper" do
|
474
|
+
it "matches" do
|
475
|
+
expect(continuing_operation).to continue_from prelude
|
476
|
+
end
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
context "with a nil state class" do
|
481
|
+
it "doesn't try to dup a nil state class" do
|
482
|
+
op = Class.new(SmashTheState::Operation).tap do |k1|
|
483
|
+
k1.class_eval do
|
484
|
+
prelude_klass = Class.new(SmashTheState::Operation)
|
485
|
+
continues_from prelude_klass
|
486
|
+
end
|
487
|
+
end
|
488
|
+
|
489
|
+
expect(op).to be_truthy
|
490
|
+
end
|
491
|
+
end
|
492
|
+
end
|
493
|
+
end
|