copycopter_client 1.0.0.beta1

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.
Files changed (38) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.textile +71 -0
  3. data/Rakefile +38 -0
  4. data/features/rails.feature +267 -0
  5. data/features/step_definitions/copycopter_server_steps.rb +65 -0
  6. data/features/step_definitions/rails_steps.rb +134 -0
  7. data/features/support/env.rb +8 -0
  8. data/features/support/rails_server.rb +118 -0
  9. data/init.rb +2 -0
  10. data/lib/copycopter_client/client.rb +117 -0
  11. data/lib/copycopter_client/configuration.rb +197 -0
  12. data/lib/copycopter_client/errors.rb +13 -0
  13. data/lib/copycopter_client/helper.rb +40 -0
  14. data/lib/copycopter_client/i18n_backend.rb +100 -0
  15. data/lib/copycopter_client/prefixed_logger.rb +41 -0
  16. data/lib/copycopter_client/rails.rb +31 -0
  17. data/lib/copycopter_client/railtie.rb +13 -0
  18. data/lib/copycopter_client/sync.rb +145 -0
  19. data/lib/copycopter_client/version.rb +8 -0
  20. data/lib/copycopter_client.rb +58 -0
  21. data/lib/tasks/copycopter_client_tasks.rake +6 -0
  22. data/spec/copycopter_client/client_spec.rb +208 -0
  23. data/spec/copycopter_client/configuration_spec.rb +252 -0
  24. data/spec/copycopter_client/helper_spec.rb +86 -0
  25. data/spec/copycopter_client/i18n_backend_spec.rb +133 -0
  26. data/spec/copycopter_client/prefixed_logger_spec.rb +25 -0
  27. data/spec/copycopter_client/sync_spec.rb +295 -0
  28. data/spec/spec.opts +2 -0
  29. data/spec/spec_helper.rb +30 -0
  30. data/spec/support/client_spec_helpers.rb +9 -0
  31. data/spec/support/defines_constants.rb +38 -0
  32. data/spec/support/fake_client.rb +42 -0
  33. data/spec/support/fake_copycopter_app.rb +136 -0
  34. data/spec/support/fake_html_safe_string.rb +20 -0
  35. data/spec/support/fake_logger.rb +68 -0
  36. data/spec/support/fake_passenger.rb +27 -0
  37. data/spec/support/fake_unicorn.rb +14 -0
  38. metadata +121 -0
@@ -0,0 +1,86 @@
1
+ require 'spec_helper'
2
+ require 'copycopter_client/helper'
3
+
4
+ describe CopycopterClient::Helper do
5
+ subject { Object.new }
6
+
7
+ before do
8
+ class << subject
9
+ include CopycopterClient::Helper
10
+ def warn(*args); end # these are annoying in test output
11
+ end
12
+ I18n.stubs(:translate)
13
+ end
14
+
15
+ Spec::Matchers.define :have_translated do |key, default|
16
+ match do |ignored_subject|
17
+ extend Mocha::API
18
+ I18n.should have_received(:translate).with(key, :default => default)
19
+ end
20
+ end
21
+
22
+ it "translates keys on CopycopterClient.s" do
23
+ CopycopterClient.s('test.key', 'default')
24
+ should have_translated("test.key", 'default')
25
+ end
26
+
27
+ it "translates keys on CopycopterClient.copy_for" do
28
+ CopycopterClient.copy_for('test.key', 'default')
29
+ should have_translated("test.key", 'default')
30
+ end
31
+
32
+ it "uses existing scope by partial key when present" do
33
+ subject.stubs(:scope_key_by_partial => "controller.action.key")
34
+ class << subject
35
+ private :scope_key_by_partial
36
+ end
37
+
38
+ subject.s(".key")
39
+
40
+ subject.should have_received(:scope_key_by_partial).with(".key")
41
+ should have_translated("controller.action.key", nil)
42
+ end
43
+
44
+ it "should prepend current partial when key starts with . and inside a view" do
45
+ template = stub(:path_without_format_and_extension => "controller/action")
46
+ subject.stubs(:template => template)
47
+
48
+ subject.s(".key")
49
+
50
+ should have_translated("controller.action.key", nil)
51
+ end
52
+
53
+ it "should prepend controller and action when key starts with . and inside a controller" do
54
+ subject.stubs(:controller_name => "controller", :action_name => "action")
55
+
56
+ subject.s(".key")
57
+
58
+ should have_translated("controller.action.key", nil)
59
+ end
60
+
61
+ describe "default assignment" do
62
+ before do
63
+ subject.stubs(:scope_copycopter_key_by_partial => '.key')
64
+ end
65
+
66
+ it "should allow a hash with key default" do
67
+ subject.s(@key, :default => "Default string")
68
+ should have_translated('.key', "Default string")
69
+ end
70
+
71
+ it "should not allow a hash with stringed key default" do
72
+ subject.s(@key, "default" => "Default string")
73
+ should have_translated('.key', nil)
74
+ end
75
+
76
+ it "should not allow a hash with key other than default" do
77
+ subject.s(@key, :junk => "Default string")
78
+ should have_translated('.key', nil)
79
+ end
80
+
81
+ it "should allow a string" do
82
+ subject.s(@key, "Default string")
83
+ should have_translated('.key', "Default string")
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,133 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopycopterClient::I18nBackend do
4
+ let(:sync) { {} }
5
+
6
+ def build_backend(config = {})
7
+ default_config = CopycopterClient::Configuration.new.to_hash
8
+ backend = CopycopterClient::I18nBackend.new(sync, default_config.update(config))
9
+ I18n.backend = backend
10
+ backend
11
+ end
12
+
13
+ before { @default_backend = I18n.backend }
14
+ after { I18n.backend = @default_backend }
15
+
16
+ subject { build_backend }
17
+
18
+ it "waits until the first download when reloaded" do
19
+ sync.stubs(:wait_for_download)
20
+
21
+ subject.reload!
22
+
23
+ sync.should have_received(:wait_for_download)
24
+ end
25
+
26
+ it "includes the base i18n backend" do
27
+ should be_kind_of(I18n::Backend::Base)
28
+ end
29
+
30
+ it "looks up a key in sync" do
31
+ value = 'hello'
32
+ sync['en.prefix.test.key'] = value
33
+
34
+ backend = build_backend
35
+
36
+ backend.translate('en', 'test.key', :scope => 'prefix').should == value
37
+ end
38
+
39
+ it "finds available locales" do
40
+ sync['en.key'] = ''
41
+ sync['fr.key'] = ''
42
+
43
+ subject.available_locales.should =~ %w(en fr)
44
+ end
45
+
46
+ it "queues missing keys with default" do
47
+ default = 'default value'
48
+
49
+ subject.translate('en', 'test.key', :default => default).should == default
50
+
51
+ sync['en.test.key'].should == default
52
+ end
53
+
54
+ it "queues missing keys without default" do
55
+ expect { subject.translate('en', 'test.key') }.
56
+ to raise_error(I18n::MissingTranslationData)
57
+
58
+ sync['en.test.key'].should == ""
59
+ end
60
+
61
+ it "queues missing keys with scope" do
62
+ default = 'default value'
63
+
64
+ subject.translate('en', 'key', :default => default, :scope => ['test']).
65
+ should == default
66
+
67
+ sync['en.test.key'].should == default
68
+ end
69
+
70
+ it "marks strings as html safe" do
71
+ sync['en.test.key'] = FakeHtmlSafeString.new("Hello")
72
+ backend = build_backend
73
+ backend.translate('en', 'test.key').should be_html_safe
74
+ end
75
+
76
+ it "looks up an array of defaults" do
77
+ sync['en.key.one'] = "Expected"
78
+ backend = build_backend
79
+ backend.translate('en', 'key.three', :default => [:"key.two", :"key.one"]).
80
+ should == 'Expected'
81
+ end
82
+
83
+ describe "with a fallback" do
84
+ let(:fallback) { I18n::Backend::Simple.new }
85
+ subject { build_backend(:fallback_backend => fallback) }
86
+
87
+ it "uses the fallback as a default" do
88
+ fallback.store_translations('en', 'test' => { 'key' => 'Expected' })
89
+ subject.translate('en', 'test.key', :default => 'Unexpected').
90
+ should include('Expected')
91
+ sync['en.test.key'].should == 'Expected'
92
+ end
93
+
94
+ it "preserves interpolation markers in the fallback" do
95
+ fallback.store_translations('en', 'test' => { 'key' => '%{interpolate}' })
96
+ subject.translate('en', 'test.key', :interpolate => 'interpolated').
97
+ should include('interpolated')
98
+ sync['en.test.key'].should == '%{interpolate}'
99
+ end
100
+
101
+ it "uses the default if the fallback doesn't have the key" do
102
+ subject.translate('en', 'test.key', :default => 'Expected').
103
+ should include('Expected')
104
+ end
105
+
106
+ it "uses the syncd key when present" do
107
+ fallback.store_translations('en', 'test' => { 'key' => 'Unexpected' })
108
+ sync['en.test.key'] = 'Expected'
109
+ subject.translate('en', 'test.key', :default => 'default').
110
+ should include('Expected')
111
+ end
112
+
113
+ it "returns a hash directly without storing" do
114
+ nested = { :nested => 'value' }
115
+ fallback.store_translations('en', 'key' => nested)
116
+ subject.translate('en', 'key', :default => 'Unexpected').should == nested
117
+ sync['en.key'].should be_nil
118
+ end
119
+
120
+ it "returns an array directly without storing" do
121
+ array = ['value']
122
+ fallback.store_translations('en', 'key' => array)
123
+ subject.translate('en', 'key', :default => 'Unexpected').should == array
124
+ sync['en.key'].should be_nil
125
+ end
126
+
127
+ it "looks up an array of defaults" do
128
+ fallback.store_translations('en', 'key' => { 'one' => 'Expected' })
129
+ subject.translate('en', 'key.three', :default => [:"key.two", :"key.one"]).
130
+ should include('Expected')
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,25 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopycopterClient::PrefixedLogger do
4
+ let(:output_logger) { FakeLogger.new }
5
+ let(:prefix) { "** NOTICE:" }
6
+ let(:thread_info) { "[P:#{Process.pid}] [T:#{Thread.current.object_id}]" }
7
+ subject { CopycopterClient::PrefixedLogger.new(prefix, output_logger) }
8
+
9
+ it "provides the prefix" do
10
+ subject.prefix.should == prefix
11
+ end
12
+
13
+ it "provides the logger" do
14
+ subject.original_logger.should == output_logger
15
+ end
16
+
17
+ [:debug, :info, :warn, :error, :fatal].each do |level|
18
+ it "prefixes #{level} log messages" do
19
+ message = 'hello'
20
+ subject.send(level, message)
21
+
22
+ output_logger.should have_entry(level, "#{prefix} #{thread_info} #{message}")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,295 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopycopterClient::Sync do
4
+ include DefinesConstants
5
+
6
+ let(:client) { FakeClient.new }
7
+
8
+ def build_sync(config = {})
9
+ config[:logger] ||= FakeLogger.new
10
+ default_config = CopycopterClient::Configuration.new.to_hash
11
+ sync = CopycopterClient::Sync.new(client, default_config.update(config))
12
+ @syncs << sync
13
+ sync
14
+ end
15
+
16
+ before do
17
+ @syncs = []
18
+ end
19
+
20
+ after { @syncs.each { |sync| sync.stop } }
21
+
22
+ it "syncs when the process terminates" do
23
+ api_key = "12345"
24
+ FakeCopycopterApp.add_project api_key
25
+ pid = fork do
26
+ config = { :logger => FakeLogger.new, :polling_delay => 86400, :api_key => api_key }
27
+ default_config = CopycopterClient::Configuration.new.to_hash.update(config)
28
+ real_client = CopycopterClient::Client.new(default_config)
29
+ sync = CopycopterClient::Sync.new(real_client, default_config)
30
+ sync.start
31
+ sleep 2
32
+ sync['test.key'] = 'value'
33
+ Signal.trap("INT") { exit }
34
+ sleep
35
+ end
36
+ sleep 3
37
+ Process.kill("INT", pid)
38
+ Process.wait
39
+ project = FakeCopycopterApp.project(api_key)
40
+ project.draft['test.key'].should == 'value'
41
+ end
42
+
43
+ it "provides access to downloaded data" do
44
+ client['en.test.key'] = 'expected'
45
+ client['en.test.other_key'] = 'expected'
46
+
47
+ sync = build_sync
48
+
49
+ sync.start
50
+
51
+ sync['en.test.key'].should == 'expected'
52
+ sync.keys.should =~ %w(en.test.key en.test.other_key)
53
+ end
54
+
55
+ it "it polls after being started" do
56
+ sync = build_sync(:polling_delay => 1)
57
+ sync.start
58
+
59
+ sync['test.key'].should be_nil
60
+
61
+ client['test.key'] = 'value'
62
+ sleep(2)
63
+
64
+ sync['test.key'].should == 'value'
65
+ end
66
+
67
+ it "stops polling when stopped" do
68
+ sync = build_sync(:polling_delay => 1)
69
+ sync.start
70
+
71
+ sync['test.key'].should be_nil
72
+
73
+ sync.stop
74
+
75
+ client['test.key'] = 'value'
76
+ sleep(2)
77
+
78
+ sync['test.key'].should be_nil
79
+ end
80
+
81
+ it "doesn't upload without changes" do
82
+ sync = build_sync(:polling_delay => 1)
83
+ sync.start
84
+ sleep(2)
85
+ client.should_not be_uploaded
86
+ end
87
+
88
+ it "uploads changes when polling" do
89
+ sync = build_sync(:polling_delay => 1)
90
+ sync.start
91
+
92
+ sync['test.key'] = 'test value'
93
+ sleep(2)
94
+
95
+ client.uploaded.should == { 'test.key' => 'test value' }
96
+ end
97
+
98
+ it "handles connection errors when polling" do
99
+ failure = "server is napping"
100
+ logger = FakeLogger.new
101
+ client.stubs(:upload).raises(CopycopterClient::ConnectionError.new(failure))
102
+ sync = build_sync(:polling_delay => 1, :logger => logger)
103
+
104
+ sync['upload.key'] = 'upload'
105
+ sync.start
106
+ sleep(2)
107
+
108
+ logger.should have_entry(:error, failure),
109
+ logger.entries.inspect
110
+
111
+ client['test.key'] = 'test value'
112
+ sleep(2)
113
+
114
+ sync['test.key'].should == 'test value'
115
+ end
116
+
117
+ it "handles an invalid api key" do
118
+ failure = "server is napping"
119
+ logger = FakeLogger.new
120
+ client.stubs(:upload).raises(CopycopterClient::InvalidApiKey.new(failure))
121
+ sync = build_sync(:polling_delay => 1, :logger => logger)
122
+
123
+ sync['upload.key'] = 'upload'
124
+ sync.start
125
+ sleep(2)
126
+
127
+ logger.should have_entry(:error, failure),
128
+ logger.entries.inspect
129
+
130
+ client['test.key'] = 'test value'
131
+ sleep(2)
132
+
133
+ sync['test.key'].should be_nil
134
+ end
135
+
136
+ it "starts after spawning when using passenger" do
137
+ logger = FakeLogger.new
138
+ passenger = define_constant('PhusionPassenger', FakePassenger.new)
139
+ passenger.become_master
140
+ sync = build_sync(:polling_delay => 1, :logger => logger)
141
+
142
+ sync.start
143
+ sleep(2)
144
+
145
+ client.should_not be_downloaded
146
+ logger.should have_entry(:info, "Registered Phusion Passenger fork hook")
147
+
148
+ passenger.spawn
149
+ sleep(2)
150
+
151
+ client.should be_downloaded
152
+ logger.should have_entry(:info, "Starting poller"),
153
+ "Got entries: #{logger.entries.inspect}"
154
+ end
155
+
156
+ it "starts after spawning when using unicorn" do
157
+ logger = FakeLogger.new
158
+ define_constant('Unicorn', Module.new)
159
+ unicorn = define_constant('Unicorn::HttpServer', FakeUnicornServer).new
160
+ unicorn.become_master
161
+ sync = build_sync(:polling_delay => 1, :logger => logger)
162
+ CopycopterClient.sync = sync
163
+
164
+ sync.start
165
+ sleep(2)
166
+
167
+ client.should_not be_downloaded
168
+ logger.should have_entry(:info, "Registered Unicorn fork hook")
169
+
170
+ unicorn.spawn
171
+ sleep(2)
172
+
173
+ client.should be_downloaded
174
+ logger.should have_entry(:info, "Starting poller")
175
+ end
176
+
177
+ it "blocks until the first download is complete" do
178
+ logger = FakeLogger.new
179
+ client.delay = 1
180
+ sync = build_sync(:logger => logger)
181
+
182
+ sync.start
183
+
184
+ finished = false
185
+ Thread.new do
186
+ sync.wait_for_download
187
+ finished = true
188
+ end
189
+
190
+ logger.should have_entry(:info, "Waiting for first sync")
191
+ finished.should == false
192
+
193
+ sleep(3)
194
+
195
+ finished.should == true
196
+ end
197
+
198
+ it "doesn't block if the first download fails" do
199
+ client.delay = 1
200
+ client.stubs(:upload).raises(StandardError.new("Failure"))
201
+ sync = build_sync
202
+
203
+ sync['test.key'] = 'value'
204
+ sync.start
205
+
206
+ finished = false
207
+ Thread.new do
208
+ sync.wait_for_download
209
+ finished = true
210
+ end
211
+
212
+ finished.should == false
213
+
214
+ sleep(4)
215
+
216
+ finished.should == true
217
+ end
218
+
219
+ it "doesn't block before starting" do
220
+ logger = FakeLogger.new
221
+ sync = build_sync(:polling_delay => 3, :logger => logger)
222
+
223
+ finished = false
224
+ Thread.new do
225
+ sync.wait_for_download
226
+ finished = true
227
+ end
228
+
229
+ sleep(1)
230
+
231
+ finished.should == true
232
+ logger.should_not have_entry(:info, "Waiting for first sync")
233
+ end
234
+
235
+ describe "given locked mutex" do
236
+ Spec::Matchers.define :finish_after_unlocking do |mutex|
237
+ match do |thread|
238
+ sleep(0.1)
239
+
240
+ if thread.status === false
241
+ violated("finished before unlocking")
242
+ else
243
+ mutex.unlock
244
+ sleep(0.1)
245
+
246
+ if thread.status === false
247
+ true
248
+ else
249
+ violated("still running after unlocking")
250
+ end
251
+ end
252
+ end
253
+
254
+ def violated(failure)
255
+ @failure_message = failure
256
+ false
257
+ end
258
+
259
+ failure_message_for_should do
260
+ @failure_message
261
+ end
262
+ end
263
+
264
+ let(:mutex) { Mutex.new }
265
+ let(:sync) { build_sync(:polling_delay => 0.1) }
266
+
267
+ before do
268
+ mutex.lock
269
+ Mutex.stubs(:new => mutex)
270
+ end
271
+
272
+ it "synchronizes read access to keys between threads" do
273
+ Thread.new { sync['test.key'] }.should finish_after_unlocking(mutex)
274
+ end
275
+
276
+ it "synchronizes read access to the key list between threads" do
277
+ Thread.new { sync.keys }.should finish_after_unlocking(mutex)
278
+ end
279
+
280
+ it "synchronizes write access to keys between threads" do
281
+ Thread.new { sync['test.key'] = 'value' }.should finish_after_unlocking(mutex)
282
+ end
283
+ end
284
+
285
+ it "starts from the top-level constant" do
286
+ sync = build_sync
287
+ CopycopterClient.sync = sync
288
+ sync.stubs(:start)
289
+
290
+ CopycopterClient.start_sync
291
+
292
+ sync.should have_received(:start)
293
+ end
294
+ end
295
+
data/spec/spec.opts ADDED
@@ -0,0 +1,2 @@
1
+ --format progress
2
+ --color
@@ -0,0 +1,30 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'spec/autorun'
4
+ require 'bourne'
5
+ require 'sham_rack'
6
+ require 'webmock/rspec'
7
+
8
+ PROJECT_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
9
+
10
+ $LOAD_PATH << File.join(PROJECT_ROOT, "lib")
11
+
12
+ require "copycopter_client"
13
+
14
+ Dir.glob(File.join(PROJECT_ROOT, "spec", "support", "**", "*.rb")).each do |file|
15
+ require(file)
16
+ end
17
+
18
+ WebMock.disable_net_connect!
19
+ ShamRack.mount(FakeCopycopterApp.new, "copycopter.com")
20
+
21
+ Spec::Runner.configure do |config|
22
+ config.include ClientSpecHelpers
23
+ config.include WebMock
24
+ config.mock_with :mocha
25
+ config.before do
26
+ FakeCopycopterApp.reset
27
+ reset_config
28
+ end
29
+ end
30
+
@@ -0,0 +1,9 @@
1
+ module ClientSpecHelpers
2
+ def reset_config
3
+ CopycopterClient.configuration = nil
4
+ CopycopterClient.configure(false) do |config|
5
+ config.api_key = 'abc123'
6
+ end
7
+ end
8
+ end
9
+
@@ -0,0 +1,38 @@
1
+ share_as :DefinesConstants do
2
+ def define_class(class_name, base = Object, &block)
3
+ class_name = class_name.to_s.camelize
4
+ klass = Class.new(base)
5
+ define_constant(class_name, klass)
6
+ klass.class_eval(&block) if block_given?
7
+ klass
8
+ end
9
+
10
+ def define_constant(path, value)
11
+ parse_constant(path) do |parent, name|
12
+ parent.const_set(name, value)
13
+ end
14
+
15
+ @defined_constants << path
16
+ value
17
+ end
18
+
19
+ def parse_constant(path)
20
+ parent_names = path.split('::')
21
+ name = parent_names.pop
22
+ parent = parent_names.inject(Object) do |ref, child_name|
23
+ ref.const_get(child_name)
24
+ end
25
+ yield(parent, name)
26
+ end
27
+
28
+ before { @defined_constants = [] }
29
+
30
+ after do
31
+ @defined_constants.reverse.each do |path|
32
+ parse_constant(path) do |parent, name|
33
+ parent.send(:remove_const, name)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
@@ -0,0 +1,42 @@
1
+ class FakeClient
2
+ def initialize
3
+ @data = {}
4
+ @uploaded = {}
5
+ @uploads = 0
6
+ @downloads = 0
7
+ end
8
+
9
+ attr_reader :uploaded, :uploads, :downloads
10
+ attr_accessor :delay
11
+
12
+ def []=(key, value)
13
+ @data[key] = value
14
+ end
15
+
16
+ def download
17
+ wait_for_delay
18
+ @downloads += 1
19
+ @data.dup
20
+ end
21
+
22
+ def upload(data)
23
+ wait_for_delay
24
+ @uploaded.update(data)
25
+ @uploads += 1
26
+ end
27
+
28
+ def uploaded?
29
+ @uploads > 0
30
+ end
31
+
32
+ def downloaded?
33
+ @downloads > 0
34
+ end
35
+
36
+ private
37
+
38
+ def wait_for_delay
39
+ sleep(delay) if delay
40
+ end
41
+ end
42
+