jmcnevin-paperclip 2.4.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. data/LICENSE +26 -0
  2. data/README.md +414 -0
  3. data/Rakefile +86 -0
  4. data/generators/paperclip/USAGE +5 -0
  5. data/generators/paperclip/paperclip_generator.rb +27 -0
  6. data/generators/paperclip/templates/paperclip_migration.rb.erb +19 -0
  7. data/init.rb +4 -0
  8. data/lib/generators/paperclip/USAGE +8 -0
  9. data/lib/generators/paperclip/paperclip_generator.rb +33 -0
  10. data/lib/generators/paperclip/templates/paperclip_migration.rb.erb +19 -0
  11. data/lib/paperclip.rb +480 -0
  12. data/lib/paperclip/attachment.rb +520 -0
  13. data/lib/paperclip/callback_compatibility.rb +61 -0
  14. data/lib/paperclip/geometry.rb +155 -0
  15. data/lib/paperclip/interpolations.rb +171 -0
  16. data/lib/paperclip/iostream.rb +45 -0
  17. data/lib/paperclip/matchers.rb +33 -0
  18. data/lib/paperclip/matchers/have_attached_file_matcher.rb +57 -0
  19. data/lib/paperclip/matchers/validate_attachment_content_type_matcher.rb +81 -0
  20. data/lib/paperclip/matchers/validate_attachment_presence_matcher.rb +54 -0
  21. data/lib/paperclip/matchers/validate_attachment_size_matcher.rb +95 -0
  22. data/lib/paperclip/missing_attachment_styles.rb +87 -0
  23. data/lib/paperclip/options.rb +78 -0
  24. data/lib/paperclip/processor.rb +58 -0
  25. data/lib/paperclip/railtie.rb +26 -0
  26. data/lib/paperclip/storage.rb +3 -0
  27. data/lib/paperclip/storage/filesystem.rb +81 -0
  28. data/lib/paperclip/storage/fog.rb +163 -0
  29. data/lib/paperclip/storage/s3.rb +270 -0
  30. data/lib/paperclip/style.rb +95 -0
  31. data/lib/paperclip/thumbnail.rb +105 -0
  32. data/lib/paperclip/upfile.rb +62 -0
  33. data/lib/paperclip/version.rb +3 -0
  34. data/lib/tasks/paperclip.rake +101 -0
  35. data/rails/init.rb +2 -0
  36. data/shoulda_macros/paperclip.rb +124 -0
  37. data/test/attachment_test.rb +1161 -0
  38. data/test/database.yml +4 -0
  39. data/test/fixtures/12k.png +0 -0
  40. data/test/fixtures/50x50.png +0 -0
  41. data/test/fixtures/5k.png +0 -0
  42. data/test/fixtures/animated.gif +0 -0
  43. data/test/fixtures/bad.png +1 -0
  44. data/test/fixtures/double spaces in name.png +0 -0
  45. data/test/fixtures/fog.yml +8 -0
  46. data/test/fixtures/s3.yml +8 -0
  47. data/test/fixtures/spaced file.png +0 -0
  48. data/test/fixtures/text.txt +1 -0
  49. data/test/fixtures/twopage.pdf +0 -0
  50. data/test/fixtures/uppercase.PNG +0 -0
  51. data/test/fog_test.rb +192 -0
  52. data/test/geometry_test.rb +206 -0
  53. data/test/helper.rb +158 -0
  54. data/test/integration_test.rb +781 -0
  55. data/test/interpolations_test.rb +202 -0
  56. data/test/iostream_test.rb +71 -0
  57. data/test/matchers/have_attached_file_matcher_test.rb +24 -0
  58. data/test/matchers/validate_attachment_content_type_matcher_test.rb +87 -0
  59. data/test/matchers/validate_attachment_presence_matcher_test.rb +26 -0
  60. data/test/matchers/validate_attachment_size_matcher_test.rb +51 -0
  61. data/test/options_test.rb +75 -0
  62. data/test/paperclip_missing_attachment_styles_test.rb +80 -0
  63. data/test/paperclip_test.rb +340 -0
  64. data/test/processor_test.rb +10 -0
  65. data/test/storage/filesystem_test.rb +56 -0
  66. data/test/storage/s3_live_test.rb +88 -0
  67. data/test/storage/s3_test.rb +689 -0
  68. data/test/style_test.rb +180 -0
  69. data/test/thumbnail_test.rb +383 -0
  70. data/test/upfile_test.rb +53 -0
  71. metadata +294 -0
@@ -0,0 +1,95 @@
1
+ # encoding: utf-8
2
+ module Paperclip
3
+ # The Style class holds the definition of a thumbnail style, applying
4
+ # whatever processing is required to normalize the definition and delaying
5
+ # the evaluation of block parameters until useful context is available.
6
+
7
+ class Style
8
+
9
+ attr_reader :name, :attachment, :format
10
+
11
+ # Creates a Style object. +name+ is the name of the attachment,
12
+ # +definition+ is the style definition from has_attached_file, which
13
+ # can be string, array or hash
14
+ def initialize name, definition, attachment
15
+ @name = name
16
+ @attachment = attachment
17
+ if definition.is_a? Hash
18
+ @geometry = definition.delete(:geometry)
19
+ @format = definition.delete(:format)
20
+ @processors = definition.delete(:processors)
21
+ @other_args = definition
22
+ else
23
+ @geometry, @format = [definition, nil].flatten[0..1]
24
+ @other_args = {}
25
+ end
26
+ @format = nil if @format.blank?
27
+ end
28
+
29
+ # retrieves from the attachment the processors defined in the has_attached_file call
30
+ # (which method (in the attachment) will call any supplied procs)
31
+ # There is an important change of interface here: a style rule can set its own processors
32
+ # by default we behave as before, though.
33
+ # if a proc has been supplied, we call it here
34
+ def processors
35
+ @processors.respond_to?(:call) ? @processors.call(attachment.instance) : (@processors || attachment.options.processors)
36
+ end
37
+
38
+ # retrieves from the attachment the whiny setting
39
+ def whiny
40
+ attachment.options.whiny
41
+ end
42
+
43
+ # returns true if we're inclined to grumble
44
+ def whiny?
45
+ !!whiny
46
+ end
47
+
48
+ def convert_options
49
+ attachment.send(:extra_options_for, name)
50
+ end
51
+
52
+ def source_file_options
53
+ attachment.send(:extra_source_file_options_for, name)
54
+ end
55
+
56
+ # returns the geometry string for this style
57
+ # if a proc has been supplied, we call it here
58
+ def geometry
59
+ @geometry.respond_to?(:call) ? @geometry.call(attachment.instance) : @geometry
60
+ end
61
+
62
+ # Supplies the hash of options that processors expect to receive as their second argument
63
+ # Arguments other than the standard geometry, format etc are just passed through from
64
+ # initialization and any procs are called here, just before post-processing.
65
+ def processor_options
66
+ args = {}
67
+ @other_args.each do |k,v|
68
+ args[k] = v.respond_to?(:call) ? v.call(attachment) : v
69
+ end
70
+ [:processors, :geometry, :format, :whiny, :convert_options, :source_file_options].each do |k|
71
+ (arg = send(k)) && args[k] = arg
72
+ end
73
+ args
74
+ end
75
+
76
+ # Supports getting and setting style properties with hash notation to ensure backwards-compatibility
77
+ # eg. @attachment.options.styles[:large][:geometry]@ will still work
78
+ def [](key)
79
+ if [:name, :convert_options, :whiny, :processors, :geometry, :format, :animated, :source_file_options].include?(key)
80
+ send(key)
81
+ elsif defined? @other_args[key]
82
+ @other_args[key]
83
+ end
84
+ end
85
+
86
+ def []=(key, value)
87
+ if [:name, :convert_options, :whiny, :processors, :geometry, :format, :animated, :source_file_options].include?(key)
88
+ send("#{key}=".intern, value)
89
+ else
90
+ @other_args[key] = value
91
+ end
92
+ end
93
+
94
+ end
95
+ end
@@ -0,0 +1,105 @@
1
+ module Paperclip
2
+ # Handles thumbnailing images that are uploaded.
3
+ class Thumbnail < Processor
4
+
5
+ attr_accessor :current_geometry, :target_geometry, :format, :whiny, :convert_options,
6
+ :source_file_options, :animated
7
+
8
+ # List of formats that we need to preserve animation
9
+ ANIMATED_FORMATS = %w(gif)
10
+
11
+ # Creates a Thumbnail object set to work on the +file+ given. It
12
+ # will attempt to transform the image into one defined by +target_geometry+
13
+ # which is a "WxH"-style string. +format+ will be inferred from the +file+
14
+ # unless specified. Thumbnail creation will raise no errors unless
15
+ # +whiny+ is true (which it is, by default. If +convert_options+ is
16
+ # set, the options will be appended to the convert command upon image conversion
17
+ #
18
+ # Options include:
19
+ #
20
+ # +geometry+ - the desired width and height of the thumbnail (required)
21
+ # +file_geometry_parser+ - an object with a method named +from_file+ that takes an image file and produces its geometry and a +transformation_to+. Defaults to Paperclip::Geometry
22
+ # +string_geometry_parser+ - an object with a method named +parse+ that takes a string and produces an object with +width+, +height+, and +to_s+ accessors. Defaults to Paperclip::Geometry
23
+ # +source_file_options+ - flags passed to the +convert+ command that influence how the source file is read
24
+ # +convert_options+ - flags passed to the +convert+ command that influence how the image is processed
25
+ # +whiny+ - whether to raise an error when processing fails. Defaults to true
26
+ # +format+ - the desired filename extension
27
+ # +animated+ - whether to merge all the layers in the image. Defaults to true
28
+ def initialize(file, options = {}, attachment = nil)
29
+ super
30
+
31
+ geometry = options[:geometry] # this is not an option
32
+ @file = file
33
+ @crop = geometry[-1,1] == '#'
34
+ @target_geometry = (options[:string_geometry_parser] || Geometry).parse(geometry)
35
+ @current_geometry = (options[:file_geometry_parser] || Geometry).from_file(@file)
36
+ @source_file_options = options[:source_file_options]
37
+ @convert_options = options[:convert_options]
38
+ @whiny = options[:whiny].nil? ? true : options[:whiny]
39
+ @format = options[:format]
40
+ @animated = options[:animated].nil? ? true : options[:animated]
41
+
42
+ @source_file_options = @source_file_options.split(/\s+/) if @source_file_options.respond_to?(:split)
43
+ @convert_options = @convert_options.split(/\s+/) if @convert_options.respond_to?(:split)
44
+
45
+ @current_format = File.extname(@file.path)
46
+ @basename = File.basename(@file.path, @current_format)
47
+
48
+ end
49
+
50
+ # Returns true if the +target_geometry+ is meant to crop.
51
+ def crop?
52
+ @crop
53
+ end
54
+
55
+ # Returns true if the image is meant to make use of additional convert options.
56
+ def convert_options?
57
+ !@convert_options.nil? && !@convert_options.empty?
58
+ end
59
+
60
+ # Performs the conversion of the +file+ into a thumbnail. Returns the Tempfile
61
+ # that contains the new image.
62
+ def make
63
+ src = @file
64
+ dst = Tempfile.new([@basename, @format ? ".#{@format}" : ''])
65
+ dst.binmode
66
+
67
+ begin
68
+ parameters = []
69
+ parameters << source_file_options
70
+ parameters << ":source"
71
+ parameters << transformation_command
72
+ parameters << convert_options
73
+ parameters << ":dest"
74
+
75
+ parameters = parameters.flatten.compact.join(" ").strip.squeeze(" ")
76
+
77
+ success = Paperclip.run("convert", parameters, :source => "#{File.expand_path(src.path)}#{'[0]' unless animated?}", :dest => File.expand_path(dst.path))
78
+ rescue Cocaine::ExitStatusError => e
79
+ raise PaperclipError, "There was an error processing the thumbnail for #{@basename}" if @whiny
80
+ rescue Cocaine::CommandNotFoundError => e
81
+ raise Paperclip::CommandNotFoundError.new("Could not run the `convert` command. Please install ImageMagick.")
82
+ end
83
+
84
+ dst
85
+ end
86
+
87
+ # Returns the command ImageMagick's +convert+ needs to transform the image
88
+ # into the thumbnail.
89
+ def transformation_command
90
+ scale, crop = @current_geometry.transformation_to(@target_geometry, crop?)
91
+ trans = []
92
+ trans << "-coalesce" if animated?
93
+ trans << "-resize" << %["#{scale}"] unless scale.nil? || scale.empty?
94
+ trans << "-crop" << %["#{crop}"] << "+repage" if crop
95
+ trans
96
+ end
97
+
98
+ protected
99
+
100
+ # Return true if the format is animated
101
+ def animated?
102
+ @animated && ANIMATED_FORMATS.include?(@current_format[1..-1]) && (ANIMATED_FORMATS.include?(@format.to_s) || @format.blank?)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,62 @@
1
+ require 'mime/types'
2
+
3
+ module Paperclip
4
+ # The Upfile module is a convenience module for adding uploaded-file-type methods
5
+ # to the +File+ class. Useful for testing.
6
+ # user.avatar = File.new("test/test_avatar.jpg")
7
+ module Upfile
8
+
9
+ # Infer the MIME-type of the file from the extension.
10
+ def content_type
11
+ types = MIME::Types.type_for(self.original_filename)
12
+ if types.length == 0
13
+ type_from_file_command
14
+ elsif types.length == 1
15
+ types.first.content_type
16
+ else
17
+ iterate_over_array_to_find_best_option(types)
18
+ end
19
+ end
20
+
21
+ def iterate_over_array_to_find_best_option(types)
22
+ types.reject {|type| type.content_type.match(/\/x-/) }.first
23
+ end
24
+
25
+ def type_from_file_command
26
+ # On BSDs, `file` doesn't give a result code of 1 if the file doesn't exist.
27
+ type = (self.original_filename.match(/\.(\w+)$/)[1] rescue "octet-stream").downcase
28
+ mime_type = (Paperclip.run("file", "-b --mime :file", :file => self.path).split(/[:;]\s+/)[0] rescue "application/x-#{type}")
29
+ mime_type = "application/x-#{type}" if mime_type.match(/\(.*?\)/)
30
+ mime_type
31
+ end
32
+
33
+ # Returns the file's normal name.
34
+ def original_filename
35
+ File.basename(self.path)
36
+ end
37
+
38
+ # Returns the size of the file.
39
+ def size
40
+ File.size(self)
41
+ end
42
+ end
43
+ end
44
+
45
+ if defined? StringIO
46
+ class StringIO
47
+ attr_accessor :original_filename, :content_type, :fingerprint
48
+ def original_filename
49
+ @original_filename ||= "stringio.txt"
50
+ end
51
+ def content_type
52
+ @content_type ||= "text/plain"
53
+ end
54
+ def fingerprint
55
+ @fingerprint ||= Digest::MD5.hexdigest(self.string)
56
+ end
57
+ end
58
+ end
59
+
60
+ class File #:nodoc:
61
+ include Paperclip::Upfile
62
+ end
@@ -0,0 +1,3 @@
1
+ module Paperclip
2
+ VERSION = "2.4.5" unless defined? Paperclip::VERSION
3
+ end
@@ -0,0 +1,101 @@
1
+ module Paperclip
2
+ module Task
3
+ def self.obtain_class
4
+ class_name = ENV['CLASS'] || ENV['class']
5
+ raise "Must specify CLASS" unless class_name
6
+ class_name
7
+ end
8
+
9
+ def self.obtain_attachments(klass)
10
+ klass = Paperclip.class_for(klass.to_s)
11
+ name = ENV['ATTACHMENT'] || ENV['attachment']
12
+ raise "Class #{klass.name} has no attachments specified" unless klass.respond_to?(:attachment_definitions)
13
+ if !name.blank? && klass.attachment_definitions.keys.include?(name)
14
+ [ name ]
15
+ else
16
+ klass.attachment_definitions.keys
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ namespace :paperclip do
23
+ desc "Refreshes both metadata and thumbnails."
24
+ task :refresh => ["paperclip:refresh:metadata", "paperclip:refresh:thumbnails"]
25
+
26
+ namespace :refresh do
27
+ desc "Regenerates thumbnails for a given CLASS (and optional ATTACHMENT and STYLES splitted by comma)."
28
+ task :thumbnails => :environment do
29
+ errors = []
30
+ klass = Paperclip::Task.obtain_class
31
+ names = Paperclip::Task.obtain_attachments(klass)
32
+ styles = (ENV['STYLES'] || ENV['styles'] || '').split(',').map(&:to_sym)
33
+ names.each do |name|
34
+ Paperclip.each_instance_with_attachment(klass, name) do |instance|
35
+ instance.send(name).reprocess!(*styles)
36
+ errors << [instance.id, instance.errors] unless instance.errors.blank?
37
+ end
38
+ end
39
+ errors.each{|e| puts "#{e.first}: #{e.last.full_messages.inspect}" }
40
+ end
41
+
42
+ desc "Regenerates content_type/size metadata for a given CLASS (and optional ATTACHMENT)."
43
+ task :metadata => :environment do
44
+ klass = Paperclip::Task.obtain_class
45
+ names = Paperclip::Task.obtain_attachments(klass)
46
+ names.each do |name|
47
+ Paperclip.each_instance_with_attachment(klass, name) do |instance|
48
+ if file = instance.send(name).to_file(:original)
49
+ instance.send("#{name}_file_name=", instance.send("#{name}_file_name").strip)
50
+ instance.send("#{name}_content_type=", file.content_type.strip)
51
+ instance.send("#{name}_file_size=", file.size) if instance.respond_to?("#{name}_file_size")
52
+ if Rails.version >= "3.0.0"
53
+ instance.save(:validate => false)
54
+ else
55
+ instance.save(false)
56
+ end
57
+ else
58
+ true
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ desc "Regenerates missing thumbnail styles for all classes using Paperclip."
65
+ task :missing_styles => :environment do
66
+ # Force loading all model classes to never miss any has_attached_file declaration:
67
+ Dir[Rails.root + 'app/models/**/*.rb'].each { |path| load path }
68
+ Paperclip.missing_attachments_styles.each do |klass, attachment_definitions|
69
+ attachment_definitions.each do |attachment_name, missing_styles|
70
+ puts "Regenerating #{klass} -> #{attachment_name} -> #{missing_styles.inspect}"
71
+ ENV['CLASS'] = klass.to_s
72
+ ENV['ATTACHMENT'] = attachment_name.to_s
73
+ ENV['STYLES'] = missing_styles.join(',')
74
+ Rake::Task['paperclip:refresh:thumbnails'].execute
75
+ end
76
+ end
77
+ Paperclip.save_current_attachments_styles!
78
+ end
79
+ end
80
+
81
+ desc "Cleans out invalid attachments. Useful after you've added new validations."
82
+ task :clean => :environment do
83
+ klass = Paperclip::Task.obtain_class
84
+ names = Paperclip::Task.obtain_attachments(klass)
85
+ names.each do |name|
86
+ Paperclip.each_instance_with_attachment(klass, name) do |instance|
87
+ unless instance.valid?
88
+ attributes = %w(file_size file_name content_type).map{ |suffix| "#{name}_#{suffix}".to_sym }
89
+ if attributes.any?{ |attribute| instance.errors[attribute].present? }
90
+ instance.send("#{name}=", nil)
91
+ if Rails.version >= "3.0.0"
92
+ instance.save(:validate => false)
93
+ else
94
+ instance.save(false)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,2 @@
1
+ require 'paperclip/railtie'
2
+ Paperclip::Railtie.insert
@@ -0,0 +1,124 @@
1
+ require 'paperclip/matchers'
2
+
3
+ module Paperclip
4
+ # =Paperclip Shoulda Macros
5
+ #
6
+ # These macros are intended for use with shoulda, and will be included into
7
+ # your tests automatically. All of the macros use the standard shoulda
8
+ # assumption that the name of the test is based on the name of the model
9
+ # you're testing (that is, UserTest is the test for the User model), and
10
+ # will load that class for testing purposes.
11
+ module Shoulda
12
+ include Matchers
13
+ # This will test whether you have defined your attachment correctly by
14
+ # checking for all the required fields exist after the definition of the
15
+ # attachment.
16
+ def should_have_attached_file name
17
+ klass = self.name.gsub(/Test$/, '').constantize
18
+ matcher = have_attached_file name
19
+ should matcher.description do
20
+ assert_accepts(matcher, klass)
21
+ end
22
+ end
23
+
24
+ # Tests for validations on the presence of the attachment.
25
+ def should_validate_attachment_presence name
26
+ klass = self.name.gsub(/Test$/, '').constantize
27
+ matcher = validate_attachment_presence name
28
+ should matcher.description do
29
+ assert_accepts(matcher, klass)
30
+ end
31
+ end
32
+
33
+ # Tests that you have content_type validations specified. There are two
34
+ # options, :valid and :invalid. Both accept an array of strings. The
35
+ # strings should be a list of content types which will pass and fail
36
+ # validation, respectively.
37
+ def should_validate_attachment_content_type name, options = {}
38
+ klass = self.name.gsub(/Test$/, '').constantize
39
+ valid = [options[:valid]].flatten
40
+ invalid = [options[:invalid]].flatten
41
+ matcher = validate_attachment_content_type(name).allowing(valid).rejecting(invalid)
42
+ should matcher.description do
43
+ assert_accepts(matcher, klass)
44
+ end
45
+ end
46
+
47
+ # Tests to ensure that you have file size validations turned on. You
48
+ # can pass the same options to this that you can to
49
+ # validate_attachment_file_size - :less_than, :greater_than, and :in.
50
+ # :less_than checks that a file is less than a certain size, :greater_than
51
+ # checks that a file is more than a certain size, and :in takes a Range or
52
+ # Array which specifies the lower and upper limits of the file size.
53
+ def should_validate_attachment_size name, options = {}
54
+ klass = self.name.gsub(/Test$/, '').constantize
55
+ min = options[:greater_than] || (options[:in] && options[:in].first) || 0
56
+ max = options[:less_than] || (options[:in] && options[:in].last) || (1.0/0)
57
+ range = (min..max)
58
+ matcher = validate_attachment_size(name).in(range)
59
+ should matcher.description do
60
+ assert_accepts(matcher, klass)
61
+ end
62
+ end
63
+
64
+ # Stubs the HTTP PUT for an attachment using S3 storage.
65
+ #
66
+ # @example
67
+ # stub_paperclip_s3('user', 'avatar', 'png')
68
+ def stub_paperclip_s3(model, attachment, extension)
69
+ definition = model.gsub(" ", "_").classify.constantize.
70
+ attachment_definitions[attachment.to_sym]
71
+
72
+ path = "http://s3.amazonaws.com/:id/#{definition[:path]}"
73
+ path.gsub!(/:([^\/\.]+)/) do |match|
74
+ "([^\/\.]+)"
75
+ end
76
+
77
+ begin
78
+ FakeWeb.register_uri(:put, Regexp.new(path), :body => "OK")
79
+ rescue NameError
80
+ raise NameError, "the stub_paperclip_s3 shoulda macro requires the fakeweb gem."
81
+ end
82
+ end
83
+
84
+ # Stub S3 and return a file for attachment. Best with Factory Girl.
85
+ # Uses a strict directory convention:
86
+ #
87
+ # features/support/paperclip
88
+ #
89
+ # This method is used by the Paperclip-provided Cucumber step:
90
+ #
91
+ # When I attach a "demo_tape" "mp3" file to a "band" on S3
92
+ #
93
+ # @example
94
+ # Factory.define :band_with_demo_tape, :parent => :band do |band|
95
+ # band.demo_tape { band.paperclip_fixture("band", "demo_tape", "png") }
96
+ # end
97
+ def paperclip_fixture(model, attachment, extension)
98
+ stub_paperclip_s3(model, attachment, extension)
99
+ base_path = File.join(File.dirname(__FILE__), "..", "..",
100
+ "features", "support", "paperclip")
101
+ File.new(File.join(base_path, model, "#{attachment}.#{extension}"))
102
+ end
103
+ end
104
+ end
105
+
106
+ if defined?(ActionController::Integration::Session)
107
+ class ActionController::Integration::Session #:nodoc:
108
+ include Paperclip::Shoulda
109
+ end
110
+ end
111
+
112
+ if defined?(FactoryGirl::Factory)
113
+ class FactoryGirl::Factory
114
+ include Paperclip::Shoulda #:nodoc:
115
+ end
116
+ else
117
+ class Factory
118
+ include Paperclip::Shoulda #:nodoc:
119
+ end
120
+ end
121
+
122
+ class Test::Unit::TestCase #:nodoc:
123
+ extend Paperclip::Shoulda
124
+ end