opium 1.5.2 → 1.5.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: df649015596245c055a437ab8d6946fa2c6badd7
4
- data.tar.gz: 35c58b1cc03b4bc34c4ea74fae80eafa691fa1e6
3
+ metadata.gz: eb9239c124be4e458967d7fe92d4073ea2cb996b
4
+ data.tar.gz: ee2dd7e877cfcd1892659b8645daa4b08cc9fc99
5
5
  SHA512:
6
- metadata.gz: 1bd35e1c4a44a923d3bbc1a2b76800b8e166b98ad450a33ec0a32b3708063224dd283e28bf4ecf5b576d1f783bd57120a556732bf659a654506fe5749957732e
7
- data.tar.gz: 71c2ab4dcc1457000e07abfac7e28165568212b86bdd61564950a605a80cd61c93c28ed82bcde2c68c122815e99b23023c749093e13a53cb4348235764640f62
6
+ metadata.gz: 770af57f72afb14bcf366157f7c3f4b7c628b44e0877e382441f628d2c2379d5e1449c07e57b7f59af6e3f4bc211e616a3d60139facf12d5f8d05d5aaff341d2
7
+ data.tar.gz: 9fb435ce9b0acfe2ebb5a18282cc6e5adb684fe5e83a818322cab5d1f04c213fb6fcdc9ff835690e9670016302fd2004da30607cfa7693415b18ff7b0cee3973
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 1.5.3
2
+ ### New Features
3
+ - #54: Installation queries are now supported by Opium::Push to perform a more finely targeted push.
4
+ - #55: Introduction of Opium::Installation, which allows for access and manipulation of Parse's Installation object.
5
+ - Opium::Push has an expanded set of attributes which may be set to further customize a push, including the ability to schedule a push for a particular time and expire pushes.
6
+
1
7
  ## 1.5.2
2
8
  ### Resolved Issues
3
9
  - Push should now use the master key for creating notifications, rather than the REST API key.
data/README.md CHANGED
@@ -56,12 +56,20 @@ A generator exists for creating new models; this should be invoked whenever `rai
56
56
  $ rails g model game title:string price:float
57
57
  ```
58
58
 
59
- A separate generate is available for creating a model to wrap Parse's User model:
59
+ A separate generator is available for creating a model to wrap Parse's User model:
60
60
 
61
61
  ```bash
62
62
  $ rails g opium:user
63
63
  ```
64
64
 
65
+ Finally, another generator is available to further customize Parse's Installation model:
66
+
67
+ ```bash
68
+ $ rails g opium:installation
69
+ ```
70
+
71
+ Both of these latter two generators otherwise accept the same arguments as the generic model generator.
72
+
65
73
  ### Specifying a model
66
74
 
67
75
  Models are defined by mixing in `Opium::Model` into a new class. Class names should match the names of the
@@ -0,0 +1,16 @@
1
+ require 'rails/generators'
2
+ require 'generators/opium/model_generator'
3
+
4
+ module Opium
5
+ module Generators
6
+ class InstallationGenerator < ::Rails::Generators::Base
7
+ desc "Creates an Opium installation model"
8
+
9
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
10
+
11
+ def run_model_generator
12
+ generate 'model', *['installation', *attributes, '--parent=opium/installation']
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ module Opium
2
+ class Installation
3
+ include Opium::Model
4
+
5
+ no_object_prefix!
6
+ requires_heightened_privileges!
7
+
8
+ field :badge, type: Integer
9
+ field :channels, type: Array
10
+ field :time_zone, type: String
11
+ field :device_type, type: Symbol, readonly: true
12
+ field :push_type, type: Symbol, readonly: true
13
+ field :gcm_sender_id, type: Integer
14
+ field :installation_id, type: String, readonly: true
15
+ field :device_token, type: String
16
+ field :channel_uris, type: Array
17
+ field :app_name, type: String
18
+ field :app_version, type: String
19
+ field :parse_version, type: String
20
+ field :app_identifier, type: String
21
+ end
22
+ end
data/lib/opium/push.rb CHANGED
@@ -4,25 +4,46 @@ module Opium
4
4
  class Push
5
5
  include Opium::Model::Connectable
6
6
 
7
+ class << self
8
+ private
9
+
10
+ def attr_hash_accessors( hash_name, *methods )
11
+ methods.each do |method_name|
12
+ attr_hash_accessor( hash_name, method_name )
13
+ end
14
+ end
15
+
16
+ def attr_hash_accessor( hash_name, method_name )
17
+ unless respond_to?( method_name )
18
+ define_method method_name do
19
+ self.send( hash_name )[ method_name ]
20
+ end
21
+ end
22
+ setter = "#{ method_name }="
23
+ unless respond_to?( setter )
24
+ define_method setter do |value|
25
+ self.send( hash_name )[ method_name ] = value
26
+ end
27
+ end
28
+ end
29
+ end
30
+
7
31
  requires_heightened_privileges!
8
32
 
9
33
  def initialize( attributes = {} )
10
34
  self.channels = []
11
35
  self.data = {}.with_indifferent_access
36
+ attributes.each {|k, v| self.send( "#{k}=", v )}
12
37
  end
13
38
 
14
- attr_accessor :channels, :data
39
+ attr_accessor :channels, :where, :data, :push_at, :expires_at, :expiration_interval
15
40
 
16
- def alert
17
- data[:alert]
18
- end
41
+ alias_method :criteria, :where
42
+ alias_method :criteria=, :where=
19
43
 
20
- def alert=( value )
21
- self.data[:alert] = value
22
- end
44
+ attr_hash_accessors :data, :alert, :badge, :sound, :content_available, :category, :uri, :title
23
45
 
24
46
  def create
25
- fail ArgumentError, 'No channels were specified!' if channels.empty?
26
47
  self.class.as_resource(:push) do
27
48
  result = self.class.http_post post_data
28
49
  result[:result]
@@ -32,10 +53,37 @@ module Opium
32
53
  private
33
54
 
34
55
  def post_data
35
- {
36
- channels: self.channels,
37
- data: self.data
38
- }
56
+ {}.tap do |pd|
57
+ targetize!( pd )
58
+ schedulize!( pd )
59
+ pd[:data] = data
60
+ end
61
+ end
62
+
63
+ def targetize!( hash )
64
+ if criteria
65
+ c = criteria
66
+ c = Installation.where( c ) unless c.is_a?( Opium::Model::Criteria )
67
+ c = c.and( channels: channels ) unless channels.empty?
68
+ hash[:where] = c.constraints[:where]
69
+ elsif !channels.empty?
70
+ hash[:channels] = channels
71
+ else
72
+ fail ArgumentError, 'No channels or criteria were specified!'
73
+ end
74
+ end
75
+
76
+ def schedulize!( hash )
77
+ fail ArgumentError, 'No scheduled time for #push_at specified!' if expiration_interval && !push_at
78
+ if push_at
79
+ fail ArgumentError, 'Can only schedule a push up to 2 weeks in advance!' if push_at > ( Time.now + ( 2 * 604800 ) )
80
+ fail ArgumentError, 'Cannot schedule pushes in the past... unless you are the Doctor' if push_at < Time.now
81
+ hash[:push_time] = push_at.iso8601
82
+ hash[:expiration_interval] = expiration_interval
83
+ elsif expires_at
84
+ fail ArgumentError, 'Cannot schedule expiration in the past... unless you have a TARDIS' if expires_at < Time.now
85
+ hash[:expiration_time] = expires_at.iso8601
86
+ end
39
87
  end
40
88
  end
41
89
  end
data/lib/opium/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Opium
2
- VERSION = "1.5.2"
2
+ VERSION = "1.5.3"
3
3
  end
data/lib/opium.rb CHANGED
@@ -6,6 +6,7 @@ require 'opium/config'
6
6
  require 'opium/extensions'
7
7
  require 'opium/model'
8
8
  require 'opium/user'
9
+ require 'opium/installation'
9
10
  require 'opium/file'
10
11
  require 'opium/schema'
11
12
  require 'opium/push'
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+
3
+ describe Opium::Installation do
4
+
5
+ it { described_class.should respond_to( :object_prefix ) }
6
+
7
+ it { should be_an( Opium::Model ) }
8
+
9
+ it { expect( described_class ).to have_heightened_privileges }
10
+
11
+ describe '#object_prefix' do
12
+ it { expect( described_class.object_prefix ).to be_empty }
13
+ end
14
+
15
+ describe '#resource_name' do
16
+ it { expect( described_class.resource_name ).to eq 'installations' }
17
+ end
18
+
19
+ %w{ badge channels time_zone device_type push_type gcm_sender_id
20
+ installation_id device_token channel_uris app_name app_version
21
+ parse_version app_identifier }.each do |field_name|
22
+ it { described_class.fields.should have_key( field_name ) }
23
+ end
24
+
25
+ %w{ device_type push_type installation_id }.each do |field_name|
26
+ describe "##{ field_name }" do
27
+ it { expect( described_class.fields[field_name] ).to be_readonly }
28
+ end
29
+ end
30
+
31
+ context 'within a subclass' do
32
+ before do
33
+ stub_const( 'SpecialInstallation', Class.new(Opium::Installation) do
34
+ field :has_web_access, type: Opium::Boolean
35
+ end )
36
+ end
37
+
38
+ subject { SpecialInstallation }
39
+
40
+ it { is_expected.to be <= Opium::Installation }
41
+ it { is_expected.to respond_to( :field, :fields ) }
42
+ it { expect( subject.fields.keys ).to include( 'badge', 'device_token', 'has_web_access' ) }
43
+
44
+ it { expect( subject ).to have_heightened_privileges }
45
+
46
+ describe '#object_prefix' do
47
+ it { expect( subject.object_prefix ).to be_empty }
48
+ end
49
+
50
+ describe '#resource_name' do
51
+ it { expect( subject.resource_name ).to eq 'installations' }
52
+ end
53
+ end
54
+ end
@@ -5,73 +5,128 @@ describe Opium::Push do
5
5
 
6
6
  it { expect( described_class ).to respond_to(:to_ruby, :to_parse).with(1).argument }
7
7
 
8
- it { is_expected.to respond_to( :create, :channels, :data, :alert ) }
8
+ it { is_expected.to respond_to( :create, :channels, :where, :data, :alert, :badge, :sound, :content_available, :category, :uri, :title, :expires_at, :push_at, :expiration_interval ) }
9
9
 
10
- describe '#alert' do
10
+ shared_examples_for 'a push option getter' do |option, value|
11
11
  let(:result) do
12
12
  subject.data = data
13
- subject.alert
13
+ subject.send(option)
14
14
  end
15
15
 
16
16
  context 'with no data' do
17
17
  let(:data) { { } }
18
18
 
19
- it 'equals data[:alert]' do
19
+ it "equals data[:#{ option }]" do
20
20
  expect( result ).to be_nil
21
21
  end
22
22
  end
23
23
 
24
- context 'with alert data' do
25
- let(:data) { { alert: 'The sky is blue.' } }
24
+ context "with a value" do
25
+ let(:data) { { option => value } }
26
26
 
27
- it 'equals data[:alert]' do
28
- expect( result ).to eq data[:alert]
27
+ it "equals data[:#{ option }]" do
28
+ expect( result ).to eq data[option]
29
29
  end
30
30
  end
31
31
  end
32
32
 
33
- describe '#alert=' do
33
+ shared_examples_for 'a push option setter' do |option, value|
34
34
  let(:result) do
35
- subject.alert = alert
36
- subject.data[:alert]
35
+ subject.send( "#{ option }=".to_sym, option_value )
36
+ subject.data[option]
37
37
  end
38
38
 
39
39
  context 'with nothing' do
40
- let(:alert) { nil }
40
+ let(:option_value) { nil }
41
41
 
42
- it 'equals data[:alert]' do
42
+ it "equals data[:#{ option }]" do
43
43
  expect( result ).to be_nil
44
44
  end
45
45
  end
46
46
 
47
- context 'with text' do
48
- let(:alert) { 'The sky is blue.' }
47
+ context 'with a value' do
48
+ let(:option_value) { value }
49
49
 
50
- it 'equals data[:alert]' do
51
- expect( result ).to eq alert
50
+ it "equals data[:#{ option }]" do
51
+ expect( result ).to eq option_value
52
52
  end
53
53
  end
54
54
  end
55
55
 
56
+ {
57
+ alert: 'The sky is blue.',
58
+ badge: 'Increment',
59
+ sound: 'cheering.caf',
60
+ content_available: 1,
61
+ category: 'A category',
62
+ uri: 'https://example.com',
63
+ title: 'Current Weather'
64
+ }.each do |option, value|
65
+ describe "##{ option }" do
66
+ it_behaves_like 'a push option getter', option, value
67
+ end
68
+
69
+ describe "##{ option }=" do
70
+ it_behaves_like 'a push option setter', option, value
71
+ end
72
+ end
73
+
56
74
  describe '#create' do
57
75
  let(:result) do
58
- subject.tap do |push|
59
- push.channels = channels
60
- push.alert = alert
61
- end.create
76
+ push.create
62
77
  end
63
78
 
79
+ let(:push) do
80
+ subject.tap do |p|
81
+ p.channels = channels if channels
82
+ p.where = criteria if criteria
83
+ p.alert = alert
84
+ p.push_at = push_at if push_at
85
+ p.expires_at = expires_at if expires_at
86
+ p.expiration_interval = expiration_interval if expiration_interval
87
+ end
88
+ end
89
+
90
+ let(:push_post_data) { push.send(:post_data) }
91
+
64
92
  let(:alert) { 'Zoo animals are fighting!' }
93
+ let(:channels) { %w{ General } }
94
+ let(:criteria) { nil }
95
+ let(:push_at) { nil }
96
+ let(:expires_at) { nil }
97
+ let(:expiration_interval) { nil }
98
+
99
+ let(:one_day_ago) { Time.now - 86400 }
100
+ let(:one_day_from_now) { Time.now + 86400 }
101
+ let(:one_week) { 604800 }
102
+ let(:one_week_from_now) { Time.now + one_week }
103
+ let(:three_weeks_from_now) { Time.now + ( 3 * one_week ) }
65
104
 
66
105
  before do
67
106
  stub_request(:post, "https://api.parse.com/1/push").
68
107
  with(body: "{\"channels\":[\"Penguins\",\"PolarBears\"],\"data\":{\"alert\":\"Zoo animals are fighting!\"}}",
69
- headers: {'Content-Type'=>'application/json', 'X-Parse-Application-Id'=>'PARSE_APP_ID', 'X-Parse-Master-Key' => 'PARSE_MASTER_KEY'}).
70
- to_return(:status => 200, :body => { result: true }.to_json, :headers => {content_type: 'application/json'})
108
+ headers: {'Content-Type'=>'application/json', 'X-Parse-Application-Id'=>'PARSE_APP_ID', 'X-Parse-Master-Key' => 'PARSE_MASTER_KEY'}).
109
+ to_return(status: 200, body: { result: true }.to_json, headers: {content_type: 'application/json'})
110
+
111
+ stub_request(:post, "https://api.parse.com/1/push").
112
+ with(body: "{\"where\":{\"deviceType\":\"ios\"},\"data\":{\"alert\":\"Zoo animals are fighting!\"}}",
113
+ headers: {'Content-Type'=>'application/json', 'X-Parse-Application-Id'=>'PARSE_APP_ID', 'X-Parse-Master-Key'=>'PARSE_MASTER_KEY'}).
114
+ to_return(status: 200, body: { result: true }.to_json, headers: { content_type: 'application/json' })
115
+
116
+ stub_request(:post, "https://api.parse.com/1/push").
117
+ with(body: "{\"where\":{\"deviceType\":\"ios\",\"channels\":[\"Penguins\",\"PolarBears\"]},\"data\":{\"alert\":\"Zoo animals are fighting!\"}}",
118
+ headers: {'Content-Type'=>'application/json', 'X-Parse-Application-Id'=>'PARSE_APP_ID', 'X-Parse-Master-Key'=>'PARSE_MASTER_KEY'}).
119
+ to_return(status: 200, body: { result: true }.to_json, headers: { content_type: 'application/json' })
120
+
121
+ stub_request(:post, "https://api.parse.com/1/push").
122
+ with(body: "{\"where\":{\"deviceType\":\"ios\",\"badge\":{\"$lte\":5},\"channels\":[\"Penguins\",\"PolarBears\"]},\"data\":{\"alert\":\"Zoo animals are fighting!\"}}",
123
+ headers: {'Content-Type'=>'application/json', 'X-Parse-Application-Id'=>'PARSE_APP_ID', 'X-Parse-Master-Key'=>'PARSE_MASTER_KEY'}).
124
+ to_return(status: 200, body: { result: true }.to_json, headers: { content_type: 'application/json' })
71
125
  end
72
126
 
73
- context 'with no channels' do
127
+ context 'with no channels or criteria' do
74
128
  let(:channels) { [] }
129
+ let(:criteria) { nil }
75
130
 
76
131
  it { expect { result }.to raise_exception( ArgumentError ) }
77
132
  end
@@ -79,8 +134,121 @@ describe Opium::Push do
79
134
  context 'with channels' do
80
135
  let(:channels) { %w{ Penguins PolarBears } }
81
136
 
137
+ it 'sets the channels key' do
138
+ expect( push_post_data ).to include( channels: channels )
139
+ end
140
+ it 'does not set the where key' do
141
+ expect( push_post_data ).to_not include( :where )
142
+ end
143
+ it { expect { result }.to_not raise_exception }
144
+ it { expect( result ).to eq true }
145
+ end
146
+
147
+ context 'with a criteria' do
148
+ let(:channels) { nil }
149
+ let(:criteria) { { device_type: :ios } }
150
+
151
+ it 'does not set the channels key' do
152
+ expect( push_post_data ).to_not include( :channels )
153
+ end
154
+ it 'sets the where key' do
155
+ expect( push_post_data ).to include( :where )
156
+ end
157
+ it 'sets the where key to a parse-friendly version' do
158
+ expect( push_post_data[:where] ).to eq( { 'deviceType' => :ios } )
159
+ end
160
+
161
+ it { expect { result }.to_not raise_exception }
162
+ it { expect( result ).to eq true }
163
+ end
164
+
165
+ context 'with channels and a criteria' do
166
+ let(:channels) { %w{ Penguins PolarBears } }
167
+ let(:criteria) { { device_type: :ios } }
168
+
169
+ it 'does not set the channels key' do
170
+ expect( push_post_data ).to_not include( :channels )
171
+ end
172
+ it 'sets the where key' do
173
+ expect( push_post_data ).to include( :where )
174
+ end
175
+ it 'adds the channels to the criteria' do
176
+ expect( push_post_data[:where] ).to include( channels: channels )
177
+ end
178
+
179
+ it { expect { result }.to_not raise_exception }
180
+ it { expect( result ).to eq true }
181
+ end
182
+
183
+ context 'with an installation criteria and channels' do
184
+ let(:channels) { %w{ Penguins PolarBears } }
185
+ let(:criteria) { Opium::Installation.where( device_type: :ios ).lte( badge: 5 ) }
186
+
187
+ it 'does not set the channels key' do
188
+ expect( push_post_data ).to_not include( :channels )
189
+ end
190
+ it 'sets the where key' do
191
+ expect( push_post_data ).to include( :where )
192
+ end
193
+ it 'adds the channels to the criteria' do
194
+ expect( push_post_data[:where] ).to include( channels: channels )
195
+ end
196
+ it 'uses the installation criterias constraints' do
197
+ expect( push_post_data[:where] ).to include( :badge )
198
+ expect( push_post_data[:where][:badge] ).to include( '$lte' => 5 )
199
+ end
200
+
82
201
  it { expect { result }.to_not raise_exception }
83
202
  it { expect( result ).to eq true }
84
203
  end
204
+
205
+ context 'with a scheduled push' do
206
+ let(:push_at) { one_day_from_now }
207
+
208
+ it { expect { push_post_data }.not_to raise_exception }
209
+ it 'sets the proper push_time' do
210
+ expect( push_post_data ).to include( push_time: push_at.iso8601 )
211
+ end
212
+ end
213
+
214
+ context 'with a scheduled push before now' do
215
+ let(:push_at) { one_day_ago }
216
+
217
+ it { expect { result }.to raise_exception( ArgumentError ) }
218
+ end
219
+
220
+ context 'with a scheduled push too long from now' do
221
+ let(:push_at) { three_weeks_from_now }
222
+
223
+ it { expect { result }.to raise_exception( ArgumentError ) }
224
+ end
225
+
226
+ context 'with a scheduled push and an expiration inverval' do
227
+ let(:push_at) { one_day_from_now }
228
+ let(:expiration_interval) { one_week }
229
+
230
+ it { expect { push_post_data }.not_to raise_exception }
231
+ it 'sets the proper push_time' do
232
+ expect( push_post_data ).to include( push_time: push_at.iso8601 )
233
+ end
234
+ it 'sets the proper expiration_interval' do
235
+ expect( push_post_data ).to include( expiration_interval: expiration_interval )
236
+ end
237
+ end
238
+
239
+ context 'without a scheduled push and with an expiration inverval' do
240
+ let(:expiration_interval) { one_week }
241
+
242
+ it { expect { result }.to raise_exception( ArgumentError ) }
243
+ end
244
+
245
+ context 'with an expiry' do
246
+ let(:expires_at) { one_week_from_now }
247
+
248
+ it { expect { push_post_data }.not_to raise_exception }
249
+ it 'sets the proper expiration_time' do
250
+ expect( push_post_data ).to include( expiration_time: expires_at.iso8601 )
251
+ end
252
+ end
85
253
  end
86
254
  end
data/spec/opium_spec.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Opium do
4
- it { expect( described_class.constants ).to include( :Model, :User, :File, :Config, :Schema, :Push ) }
4
+ it { expect( described_class.constants ).to include( :Model, :User, :Installation, :File, :Config, :Schema, :Push ) }
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opium
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Bowers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-01-28 00:00:00.000000000 Z
11
+ date: 2017-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -252,6 +252,7 @@ files:
252
252
  - README.md
253
253
  - Rakefile
254
254
  - lib/generators/opium/config_generator.rb
255
+ - lib/generators/opium/installation_generator.rb
255
256
  - lib/generators/opium/model_generator.rb
256
257
  - lib/generators/opium/templates/config.yml
257
258
  - lib/generators/opium/templates/model.rb
@@ -277,6 +278,7 @@ files:
277
278
  - lib/opium/extensions/time.rb
278
279
  - lib/opium/extensions/true_class.rb
279
280
  - lib/opium/file.rb
281
+ - lib/opium/installation.rb
280
282
  - lib/opium/model.rb
281
283
  - lib/opium/model/attributable.rb
282
284
  - lib/opium/model/batchable.rb
@@ -324,6 +326,7 @@ files:
324
326
  - spec/opium/extensions/symbol_spec.rb
325
327
  - spec/opium/extensions/time_spec.rb
326
328
  - spec/opium/file_spec.rb
329
+ - spec/opium/installation_spec.rb
327
330
  - spec/opium/model/attributable_spec.rb
328
331
  - spec/opium/model/batchable/batch_spec.rb
329
332
  - spec/opium/model/batchable/operation_spec.rb
@@ -394,6 +397,7 @@ test_files:
394
397
  - spec/opium/extensions/symbol_spec.rb
395
398
  - spec/opium/extensions/time_spec.rb
396
399
  - spec/opium/file_spec.rb
400
+ - spec/opium/installation_spec.rb
397
401
  - spec/opium/model/attributable_spec.rb
398
402
  - spec/opium/model/batchable/batch_spec.rb
399
403
  - spec/opium/model/batchable/operation_spec.rb