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.
@@ -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