copycopter_client 1.0.0.beta1

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