ungulate 0.1.4 → 0.2.0

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 (42) hide show
  1. data/VERSION +1 -1
  2. data/bin/ungulate_server.rb +22 -7
  3. data/features/image_resize.feature +2 -12
  4. data/features/notification_url.feature +14 -0
  5. data/features/resize_then_composite.feature +2 -1
  6. data/features/step_definitions/bucket_steps.rb +4 -0
  7. data/features/step_definitions/command_steps.rb +10 -1
  8. data/features/step_definitions/notification_steps.rb +23 -0
  9. data/features/step_definitions/queue_steps.rb +7 -21
  10. data/features/step_definitions/version_steps.rb +2 -2
  11. data/features/{support.rb → support/config.rb} +4 -0
  12. data/features/support/helpers.rb +21 -0
  13. data/features/support/put_server.rb +22 -0
  14. data/lib/ungulate.rb +64 -0
  15. data/lib/ungulate/blob_processor.rb +34 -0
  16. data/lib/ungulate/curl_http.rb +25 -0
  17. data/lib/ungulate/file_upload.rb +22 -16
  18. data/lib/ungulate/job.rb +23 -143
  19. data/lib/ungulate/rmagick_version_creator.rb +77 -0
  20. data/lib/ungulate/s3_storage.rb +41 -0
  21. data/lib/ungulate/server.rb +27 -7
  22. data/lib/ungulate/sqs_message_queue.rb +38 -0
  23. data/spec/fixtures/chuckle.jpg +0 -0
  24. data/spec/fixtures/chuckle.png +0 -0
  25. data/spec/fixtures/chuckle_converted.png +0 -0
  26. data/spec/fixtures/chuckle_converted_badly.png +0 -0
  27. data/spec/fixtures/chuckle_thumbnail.png +0 -0
  28. data/spec/fixtures/watermark.png +0 -0
  29. data/spec/lib/ungulate/blob_processor_spec.rb +68 -0
  30. data/spec/lib/ungulate/curl_http_spec.rb +20 -0
  31. data/spec/{ungulate → lib/ungulate}/file_upload_spec.rb +10 -27
  32. data/spec/lib/ungulate/job_spec.rb +120 -0
  33. data/spec/lib/ungulate/rmagick_version_creator_spec.rb +97 -0
  34. data/spec/lib/ungulate/s3_storage_spec.rb +74 -0
  35. data/spec/lib/ungulate/server_spec.rb +44 -0
  36. data/spec/lib/ungulate/sqs_message_queue_spec.rb +28 -0
  37. data/spec/{ungulate → lib/ungulate}/view_helpers_spec.rb +1 -1
  38. data/spec/lib/ungulate_spec.rb +24 -0
  39. data/spec/spec_helper.rb +73 -0
  40. metadata +67 -25
  41. data/spec/ungulate/job_spec.rb +0 -386
  42. data/spec/ungulate/server_spec.rb +0 -42
@@ -0,0 +1,20 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/curl_http'
3
+
4
+ module Ungulate
5
+ describe CurlHttp do
6
+ subject do
7
+ CurlHttp.new(:logger => ::Logger.new(nil))
8
+ end
9
+
10
+ it "can return the body of a resource from https" do
11
+ subject.get_body('https://dmxno528jhfy0.cloudfront.net/superhug-watermark.png').
12
+ should == fixture('watermark.png')
13
+ end
14
+
15
+ it "can PUT to a HTTP URL" do
16
+ response = subject.put('https://dmxno528jhfy0.cloudfront.net/superhug-watermark.png')
17
+ response.code.should == 403
18
+ end
19
+ end
20
+ end
@@ -1,5 +1,6 @@
1
- require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
1
+ require 'spec_helper'
2
2
  require 'ungulate/file_upload'
3
+ require 'active_support/core_ext/numeric/time'
3
4
 
4
5
  module Ungulate
5
6
  describe FileUpload do
@@ -11,14 +12,15 @@ module Ungulate
11
12
  let(:queue_name) { 'some-queue-name' }
12
13
 
13
14
  before do
14
- FileUpload.access_key_id = access_key_id
15
- FileUpload.secret_access_key = secret_access_key
16
- FileUpload.queue_name = queue_name
15
+ Ungulate.configure do |config|
16
+ config.access_key_id = access_key_id
17
+ config.secret_access_key = secret_access_key
18
+ config.queue_name = 'not-used'
19
+ config.queue = lambda { q }
20
+ end
17
21
  end
18
22
 
19
23
  its(:access_key_id) { should == access_key_id }
20
- its(:queue_name) { should == queue_name }
21
- its(:secret_access_key) { should == secret_access_key }
22
24
 
23
25
  context "policy set directly" do
24
26
  let(:policy) do
@@ -94,12 +96,11 @@ module Ungulate
94
96
  end
95
97
 
96
98
  describe "enqueue" do
97
- let(:q) { stub 'queue' }
99
+ let(:q) { double 'queue' }
98
100
  let(:job_hash) { stub('Hash', :to_yaml => :some_yaml) }
99
- before { Ungulate::FileUpload.stub(:queue).and_return(q) }
100
101
 
101
102
  it "queues the yamlised version of the passed job hash" do
102
- q.should_receive(:send_message).with(:some_yaml)
103
+ q.should_receive(:push).with(:some_yaml)
103
104
  Ungulate::FileUpload.enqueue(job_hash)
104
105
  end
105
106
  end
@@ -153,24 +154,6 @@ module Ungulate
153
154
  end
154
155
  end
155
156
 
156
- describe "queue" do
157
- let(:sqs) do
158
- sqs = stub 'SQS'
159
- sqs.stub(:queue).with(queue_name).and_return(:queue_instance)
160
- sqs
161
- end
162
-
163
- subject { Ungulate::FileUpload.queue }
164
-
165
- before do
166
- RightAws::SqsGen2.stub(:new).
167
- with(access_key_id, secret_access_key).
168
- and_return(sqs)
169
- end
170
-
171
- it { should == :queue_instance }
172
- end
173
-
174
157
  describe "signature" do
175
158
  let(:sha1) { stub 'SHA1' }
176
159
 
@@ -0,0 +1,120 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/job'
3
+
4
+ module Ungulate
5
+ describe Job do
6
+ subject do
7
+ Job.new(:blob_processor => blob_processor, :storage => storage,
8
+ :http => http, :logger => ::Logger.new(nil))
9
+ end
10
+
11
+ let(:http) { double 'http' }
12
+ let(:blob_processor) { double 'blob processor' }
13
+ let(:storage) { double 'storage', :bucket => bucket }
14
+ let(:bucket) { double 'bucket', :retrieve => nil }
15
+ let(:versions) do
16
+ {
17
+ :large => [ :resize_to_fill, 400, 300 ],
18
+ :medium => [ :resize_to_fit, 300, 200 ],
19
+ :thumbnail => [ :resize_to_fit, 40, 20 ]
20
+ }
21
+ end
22
+ let(:job_description) do
23
+ {
24
+ :bucket => 'some-bucket',
25
+ :key => 'original-key.jpg',
26
+ :versions => versions
27
+ }
28
+ end
29
+ let(:job_encoded) { job_description.to_yaml }
30
+
31
+ it "gets an original blob and sends it to be processed" do
32
+ blob = double 'blob'
33
+
34
+ storage.should_receive(:bucket).with('some-bucket', :listener => subject).
35
+ and_return(bucket)
36
+ bucket.should_receive(:retrieve).with('original-key.jpg').and_return(blob)
37
+
38
+ blob_processor.should_receive(:process).
39
+ with(:blob => blob, :versions => versions, :bucket => bucket,
40
+ :original_key => 'original-key.jpg')
41
+
42
+ subject.process(job_encoded)
43
+ end
44
+
45
+ context "with no notification URL" do
46
+ it "accepts storage complete messages, but does nothing" do
47
+ storage.stub(:get)
48
+ blob_processor.stub(:process)
49
+ subject.process(job_encoded)
50
+ subject.storage_complete(:large)
51
+ subject.storage_complete(:medium)
52
+
53
+ http.should_not_receive(:put)
54
+ subject.storage_complete(:thumbnail)
55
+ end
56
+ end
57
+
58
+ context "when notification URL set" do
59
+ let(:job_description) do
60
+ {
61
+ :bucket => 'some-bucket',
62
+ :key => 'original-key.jpg',
63
+ :notification_url => 'http://some.url',
64
+ :versions => versions
65
+ }
66
+ end
67
+
68
+ before do
69
+ storage.stub(:get)
70
+ blob_processor.stub(:process)
71
+ subject.process(job_encoded)
72
+ end
73
+
74
+ context "with only one version" do
75
+ let(:versions) { { :large => [ :resize_to_fill, 400, 300 ] } }
76
+
77
+ it "PUTs to the notification URL when only version stored" do
78
+ http.should_receive(:put).with('http://some.url')
79
+ subject.storage_complete(:large)
80
+ end
81
+ end
82
+
83
+ context "with three versions" do
84
+ let(:versions) do
85
+ {
86
+ :large => [ :resize_to_fill, 400, 300 ],
87
+ :medium => [ :resize_to_fit, 300, 200 ],
88
+ :thumbnail => [ :resize_to_fit, 40, 20 ]
89
+ }
90
+ end
91
+
92
+ it "PUTs to the notification URL when third version stored" do
93
+ subject.storage_complete(:large)
94
+ subject.storage_complete(:medium)
95
+
96
+ http.should_receive(:put).with('http://some.url')
97
+ subject.storage_complete(:thumbnail)
98
+ end
99
+ end
100
+
101
+ context "with a different URL" do
102
+ let(:job_description) do
103
+ {
104
+ :bucket => 'some-bucket',
105
+ :key => 'original-key.jpg',
106
+ :notification_url => 'http://some.other.url',
107
+ :versions => versions
108
+ }
109
+ end
110
+
111
+ it "uses the other URL" do
112
+ subject.storage_complete(:large)
113
+ subject.storage_complete(:medium)
114
+ http.should_receive(:put).with('http://some.other.url')
115
+ subject.storage_complete(:thumbnail)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,97 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/rmagick_version_creator'
3
+
4
+ module Ungulate
5
+ describe RmagickVersionCreator do
6
+ subject do
7
+ RmagickVersionCreator.new(:logger => ::Logger.new(nil),
8
+ :http => http)
9
+ end
10
+
11
+ let(:http) { double 'http' }
12
+
13
+ shared_examples_for "an image converter" do
14
+ it "creates a matching blob" do
15
+ new_blob = subject.create(original, instructions)[:blob]
16
+
17
+ expected_image = Magick::Image.from_blob(converted).first
18
+ got_image = Magick::Image.from_blob(new_blob).first
19
+
20
+ expected_image.difference(got_image)
21
+
22
+ puts "good image:"
23
+ puts "mean per pixel: #{expected_image.mean_error_per_pixel}"
24
+ puts "normalized mean: #{expected_image.normalized_mean_error}"
25
+ puts "normalized max: #{expected_image.normalized_maximum_error}"
26
+
27
+ expected_image.mean_error_per_pixel.round.should be_zero
28
+ expected_image.normalized_maximum_error.round.should be_zero
29
+
30
+ expected_image.destroy!
31
+ got_image.destroy!
32
+ end
33
+
34
+ context "for a png" do
35
+ it "includes image/png as the content-type in the return hash" do
36
+ subject.create(original, instructions)[:content_type].
37
+ should == 'image/png'
38
+ end
39
+ end
40
+
41
+ context "for a jpeg" do
42
+ let(:original) { fixture 'chuckle.jpg' }
43
+
44
+ it "includes image/jpeg as the content-type in the return hash" do
45
+ subject.create(original, instructions)[:content_type].
46
+ should == 'image/jpeg'
47
+ end
48
+ end
49
+ end
50
+
51
+ context "resize to fill only" do
52
+ let(:original) { fixture 'chuckle.png' }
53
+ let(:converted) { fixture 'chuckle_thumbnail.png' }
54
+ let(:instructions) { [ :resize_to_fill, 80, 80 ] }
55
+ it_behaves_like "an image converter"
56
+ end
57
+
58
+ context "resize to fit and then composite" do
59
+ let(:url) { "https://some/watermark.png" }
60
+ let(:original) { fixture 'chuckle.png' }
61
+ let(:converted) { fixture 'chuckle_converted.png' }
62
+ let(:bad) { fixture 'chuckle_converted_badly.png' }
63
+
64
+ let(:instructions) do
65
+ [
66
+ [ :resize_to_fit, 628, 464 ],
67
+ [ :composite, url, :center_gravity, :soft_light_composite_op ]
68
+ ]
69
+ end
70
+
71
+ before do
72
+ http.should_receive(:get_body).with(url).and_return fixture('watermark.png')
73
+ end
74
+
75
+ it_behaves_like "an image converter"
76
+
77
+ it "doesn't compare well with a broken image" do
78
+ new_blob = subject.create(original, instructions)[:blob]
79
+ got_image = Magick::Image.from_blob(new_blob).first
80
+ bad_image = Magick::Image.from_blob(bad).first
81
+
82
+ bad_image.difference(got_image)
83
+
84
+ puts "bad image:"
85
+ puts "mean per pixel: #{bad_image.mean_error_per_pixel}"
86
+ puts "normalized mean: #{bad_image.normalized_mean_error}"
87
+ puts "normalized max: #{bad_image.normalized_maximum_error}"
88
+
89
+ bad_image.mean_error_per_pixel.round.should > 0
90
+ bad_image.normalized_maximum_error.round.should > 0
91
+
92
+ bad_image.destroy!
93
+ got_image.destroy!
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,74 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/s3_storage'
3
+
4
+ # integration test
5
+ module Ungulate
6
+ describe S3Storage do
7
+ def new_storage
8
+ S3Storage.new(
9
+ :access_key_id => ENV['AMAZON_ACCESS_KEY_ID'],
10
+ :secret_access_key => ENV['AMAZON_SECRET_ACCESS_KEY']
11
+ )
12
+ end
13
+
14
+ subject { new_storage }
15
+
16
+ it "can store and retrieve blobs with keys" do
17
+ bucket = subject.bucket('ungulate-test')
18
+ bucket.clear
19
+ bucket.store('somekey', 'somedata')
20
+ bucket.retrieve('somekey').should == 'somedata'
21
+
22
+ bucket.store('someotherkey', 'someotherdata')
23
+ bucket.retrieve('someotherkey').should == 'someotherdata'
24
+ end
25
+
26
+ it "persists data across instances" do
27
+ bucket = subject.bucket('ungulate-test')
28
+ bucket.clear
29
+ bucket.store('somekey', 'somedata')
30
+
31
+ new_storage.bucket('ungulate-test').
32
+ retrieve('somekey').should == 'somedata'
33
+ end
34
+
35
+ it "stores publicly accessible items with a long cache expiry" do
36
+ key = 'somekey'
37
+ bucket = subject.bucket('ungulate-test')
38
+ bucket.clear
39
+ bucket.store(key, 'somedata')
40
+
41
+ response = Curl::Easy.http_get("ungulate-test.s3.amazonaws.com/#{key}")
42
+ response.header_str.should include('Cache-Control: max-age=2629743')
43
+ end
44
+
45
+ it "sets the correct content-type" do
46
+ key = 'somekey.png'
47
+ bucket = subject.bucket('ungulate-test')
48
+ bucket.clear
49
+ bucket.store(key, 'somedata', :content_type => 'image/png')
50
+ response = Curl::Easy.http_get("ungulate-test.s3.amazonaws.com/#{key}")
51
+ response.content_type.should == 'image/png'
52
+ end
53
+
54
+ context "when listener set" do
55
+ it "notifies the listener when it's done" do
56
+ listener = double 'listener'
57
+ bucket = subject.bucket('ungulate-test', :listener => listener)
58
+
59
+ listener.should_receive(:storage_complete).with(:large)
60
+ bucket.store('large', 'largedata', :version => :large)
61
+ end
62
+
63
+ context "but no version passed" do
64
+ it "does not notify the listener" do
65
+ listener = double 'listener'
66
+ bucket = subject.bucket('ungulate-test', :listener => listener)
67
+
68
+ listener.should_not_receive(:storage_complete)
69
+ bucket.store('large', 'largedata')
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,44 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/server'
3
+
4
+ module Ungulate
5
+ describe Server do
6
+ subject do
7
+ Server.new(:job_processor => processor, :queue => queue,
8
+ :logger => ::Logger.new(nil))
9
+ end
10
+
11
+ let(:processor) { double 'job processor' }
12
+ let(:queue_name) { 'queuename' }
13
+ let(:queue) { double 'queue', :name => 'Some Queue' }
14
+ let(:message) { double 'message', :to_s => '' }
15
+
16
+ it "receives a message from the provided queue and processes it with the processor" do
17
+ queue.should_receive(:receive).ordered.and_return(message)
18
+ message.should_receive(:to_s).and_return('job description')
19
+ processor.should_receive(:process).with('job description').ordered
20
+ message.should_receive(:delete).ordered
21
+
22
+ subject.run
23
+ end
24
+
25
+ it "returns truthy from run if it processed something" do
26
+ queue.stub(:receive).and_return(message)
27
+ message.stub(:delete)
28
+ processor.stub(:process).and_return(true)
29
+ subject.run.should be_true
30
+ end
31
+
32
+ it "returns whatever the processor returns" do
33
+ queue.stub(:receive).and_return(message)
34
+ message.stub(:delete)
35
+ processor.stub(:process).and_return('some message')
36
+ subject.run.should == 'some message'
37
+ end
38
+
39
+ it "returns falsey from run if it did not process anything" do
40
+ queue.stub(:receive).and_return(nil)
41
+ subject.run.should be_false
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+ require 'ungulate/sqs_message_queue'
3
+
4
+ # integration test - talks to real queue
5
+ #
6
+ # to spec a new message queue, copy this spec and change the require and
7
+ # describe lines, and change new_queue to instantiate your queue class
8
+ # message queues are always passed the options below, from the environment
9
+ #
10
+ # note that you'll probably need to wrap the messages returned from your queue
11
+ # to behave like messages from SQS, i.e. they need to implement 'to_s' to convert
12
+ # them to a string, and 'delete' to delete them from the queue
13
+ module Ungulate
14
+ describe SqsMessageQueue do
15
+ def new_queue
16
+ SqsMessageQueue.new('some_test_queue',
17
+ :access_key_id => ENV['AMAZON_ACCESS_KEY_ID'],
18
+ :secret_access_key => ENV['AMAZON_SECRET_ACCESS_KEY'],
19
+ :server => ENV['QUEUE_SERVER'])
20
+ end
21
+
22
+ it_behaves_like "a message queue"
23
+
24
+ it "raises an exception if queue name not set" do
25
+ expect { SqsMessageQueue.new('', {}) }.to raise_error(MissingConfiguration)
26
+ end
27
+ end
28
+ end