smash_the_state 1.2.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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