Testing File Attachment Implementation in Paperclip
This integration test suite validates Paperclip’s attachment functionality across multiple models and storage systems. It covers file handling, image processing, and storage interactions with both filesystem and S3 storage backends.
Test Coverage Overview
Implementation Analysis
Technical Details
Best Practices Demonstrated
thoughtbot/paperclip
spec/paperclip/integration_spec.rb
require 'spec_helper'
require 'open-uri'
describe 'Paperclip' do
around do |example|
files_before = ObjectSpace.each_object(Tempfile).select do |file|
file.path && File.file?(file.path)
end
example.run
files_after = ObjectSpace.each_object(Tempfile).select do |file|
file.path && File.file?(file.path)
end
diff = files_after - files_before
expect(diff).to eq([]), "Leaked tempfiles: #{diff.inspect}"
end
context "Many models at once" do
before do
rebuild_model
@file = File.new(fixture_file("5k.png"), 'rb')
# Deals with `Too many open files` error
dummies = Array.new(300) { Dummy.new avatar: @file }
Dummy.import dummies
# save attachment instances to run after hooks including tempfile cleanup
# since activerecord-import does not use our usually hooked-in hooks
# (such as after_save)
dummies.each { |dummy| dummy.avatar.save }
end
after { @file.close }
it "does not exceed the open file limit" do
assert_nothing_raised do
Dummy.all.each { |dummy| dummy.avatar }
end
end
end
context "An attachment" do
before do
rebuild_model styles: { thumb: "50x50#" }
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
@dummy.avatar = @file
assert @dummy.save
end
after { @file.close }
it "creates its thumbnails properly" do
assert_match(/\b50x50\b/, `identify "#{@dummy.avatar.path(:thumb)}"`)
end
context 'reprocessing with unreadable original' do
before { File.chmod(0000, @dummy.avatar.path) }
it "does not raise an error" do
assert_nothing_raised do
silence_stream(STDERR) do
@dummy.avatar.reprocess!
end
end
end
it "returns false" do
silence_stream(STDERR) do
assert [email protected]!
end
end
after { File.chmod(0644, @dummy.avatar.path) }
end
context "redefining its attachment styles" do
before do
Dummy.class_eval do
has_attached_file :avatar, styles: { thumb: "150x25#", dynamic: lambda { |a| '50x50#' } }
end
@d2 = Dummy.find(@dummy.id)
@original_timestamp = @d2.avatar_updated_at
@d2.avatar.reprocess!
@d2.save
end
it "creates its thumbnails properly" do
assert_match(/\b150x25\b/, `identify "#{@dummy.avatar.path(:thumb)}"`)
assert_match(/\b50x50\b/, `identify "#{@dummy.avatar.path(:dynamic)}"`)
end
it "changes the timestamp" do
assert_not_equal @original_timestamp, @d2.avatar_updated_at
end
end
end
context "Attachment" do
before do
@thumb_path = "tmp/public/system/dummies/avatars/000/000/001/thumb/5k.png"
File.delete(@thumb_path) if File.exist?(@thumb_path)
rebuild_model styles: { thumb: "50x50#" }
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
end
after { @file.close }
it "does not create the thumbnails upon saving when post-processing is disabled" do
@dummy.avatar.post_processing = false
@dummy.avatar = @file
assert @dummy.save
assert_file_not_exists @thumb_path
end
it "creates the thumbnails upon saving when post_processing is enabled" do
@dummy.avatar.post_processing = true
@dummy.avatar = @file
assert @dummy.save
assert_file_exists @thumb_path
end
end
context "Attachment with no generated thumbnails" do
before do
@thumb_small_path = "tmp/public/system/dummies/avatars/000/000/001/thumb_small/5k.png"
@thumb_large_path = "tmp/public/system/dummies/avatars/000/000/001/thumb_large/5k.png"
File.delete(@thumb_small_path) if File.exist?(@thumb_small_path)
File.delete(@thumb_large_path) if File.exist?(@thumb_large_path)
rebuild_model styles: { thumb_small: "50x50#", thumb_large: "60x60#" }
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
@dummy.avatar.post_processing = false
@dummy.avatar = @file
assert @dummy.save
@dummy.avatar.post_processing = true
end
after { @file.close }
it "allows us to create all thumbnails in one go" do
assert_file_not_exists(@thumb_small_path)
assert_file_not_exists(@thumb_large_path)
@dummy.avatar.reprocess!
assert_file_exists(@thumb_small_path)
assert_file_exists(@thumb_large_path)
end
it "allows us to selectively create each thumbnail" do
skip <<-EXPLANATION
#reprocess! calls #assign which calls Paperclip.io_adapters.for
which creates the tempfile. #assign then calls #post_process_file which
calls MediaTypeSpoofDetectionValidator#validate_each which calls
Paperclip.io_adapters.for, which creates another tempfile. That first
tempfile is the one that leaks.
EXPLANATION
assert_file_not_exists(@thumb_small_path)
assert_file_not_exists(@thumb_large_path)
@dummy.avatar.reprocess! :thumb_small
assert_file_exists(@thumb_small_path)
assert_file_not_exists(@thumb_large_path)
@dummy.avatar.reprocess! :thumb_large
assert_file_exists(@thumb_large_path)
end
end
context "A model that modifies its original" do
before do
rebuild_model styles: { original: "2x2#" }
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
@dummy.avatar = @file
end
it "reports the file size of the processed file and not the original" do
assert_not_equal File.size(@file.path), @dummy.avatar.size
end
after do
@file.close
# save attachment instance to run after hooks (including tempfile cleanup)
@dummy.avatar.save
end
end
context "A model with attachments scoped under an id" do
before do
rebuild_model styles: { large: "100x100",
medium: "50x50" },
path: ":rails_root/tmp/:id/:attachments/:style.:extension"
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
@dummy.avatar = @file
end
after { @file.close }
context "when saved" do
before do
@dummy.save
@saved_path = @dummy.avatar.path(:large)
end
it "has a large file in the right place" do
assert_file_exists(@dummy.avatar.path(:large))
end
context "and deleted" do
before do
@dummy.avatar.clear
@dummy.save
end
it "does not have a large file in the right place anymore" do
assert_file_not_exists(@saved_path)
end
it "does not have its next two parent directories" do
assert_file_not_exists(File.dirname(@saved_path))
assert_file_not_exists(File.dirname(File.dirname(@saved_path)))
end
end
context 'and deleted where the delete fails' do
it "does not die if an unexpected SystemCallError happens" do
FileUtils.stubs(:rmdir).raises(Errno::EPIPE)
assert_nothing_raised do
@dummy.avatar.clear
@dummy.save
end
end
end
end
end
[000,002,022].each do |umask|
context "when the umask is #{umask}" do
before do
rebuild_model
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
@umask = File.umask(umask)
end
after do
File.umask @umask
@file.close
end
it "respects the current umask" do
@dummy.avatar = @file
@dummy.save
assert_equal 0666&~umask, 0666&File.stat(@dummy.avatar.path).mode
end
end
end
[0666,0664,0640].each do |perms|
context "when the perms are #{perms}" do
before do
rebuild_model override_file_permissions: perms
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
end
after do
@file.close
end
it "respects the current perms" do
@dummy.avatar = @file
@dummy.save
assert_equal perms, File.stat(@dummy.avatar.path).mode & 0777
end
end
end
it "skips chmod operation, when override_file_permissions is set to false (e.g. useful when using CIFS mounts)" do
FileUtils.expects(:chmod).never
rebuild_model override_file_permissions: false
dummy = Dummy.create!
dummy.avatar = @file
dummy.save
end
context "A model with a filesystem attachment" do
before do
rebuild_model styles: { large: "300x300>",
medium: "100x100",
thumb: ["32x32#", :gif] },
default_style: :medium,
url: "/:attachment/:class/:style/:id/:basename.:extension",
path: ":rails_root/tmp/:attachment/:class/:style/:id/:basename.:extension"
@dummy = Dummy.new
@file = File.new(fixture_file("5k.png"), 'rb')
@bad_file = File.new(fixture_file("bad.png"), 'rb')
assert @dummy.avatar = @file
assert @dummy.valid?, @dummy.errors.full_messages.join(", ")
assert @dummy.save
end
after { [@file, @bad_file].each(&:close) }
it "writes and delete its files" do
[["434x66", :original],
["300x46", :large],
["100x15", :medium],
["32x32", :thumb]].each do |geo, style|
cmd = %Q[identify -format "%wx%h" "#{@dummy.avatar.path(style)}"]
assert_equal geo, `#{cmd}`.chomp, cmd
end
saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) }
@d2 = Dummy.find(@dummy.id)
assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path}"`.chomp
assert_equal "434x66", `identify -format "%wx%h" "#{@d2.avatar.path(:original)}"`.chomp
assert_equal "300x46", `identify -format "%wx%h" "#{@d2.avatar.path(:large)}"`.chomp
assert_equal "100x15", `identify -format "%wx%h" "#{@d2.avatar.path(:medium)}"`.chomp
assert_equal "32x32", `identify -format "%wx%h" "#{@d2.avatar.path(:thumb)}"`.chomp
assert @dummy.valid?
assert @dummy.save
saved_paths.each do |p|
assert_file_exists(p)
end
@dummy.avatar.clear
assert_nil @dummy.avatar_file_name
assert @dummy.valid?
assert @dummy.save
saved_paths.each do |p|
assert_file_not_exists(p)
end
@d2 = Dummy.find(@dummy.id)
assert_nil @d2.avatar_file_name
end
it "works exactly the same when new as when reloaded" do
@d2 = Dummy.find(@dummy.id)
assert_equal @dummy.avatar_file_name, @d2.avatar_file_name
[:thumb, :medium, :large, :original].each do |style|
assert_equal @dummy.avatar.path(style), @d2.avatar.path(style)
end
saved_paths = [:thumb, :medium, :large, :original].collect{|s| @dummy.avatar.path(s) }
@d2.avatar.clear
assert @d2.save
saved_paths.each do |p|
assert_file_not_exists(p)
end
end
it "does not abide things that don't have adapters" do
assert_raises(Paperclip::AdapterRegistry::NoHandlerError) do
@dummy.avatar = "not a file"
end
end
it "is not ok with bad files" do
@dummy.avatar = @bad_file
assert ! @dummy.valid?
# save attachment instance to run after hooks (including tempfile cleanup)
@dummy.avatar.save
end
it "knows the difference between good files, bad files, and not files when validating" do
Dummy.validates_attachment_presence :avatar
@d2 = Dummy.find(@dummy.id)
@d2.avatar = @file
assert @d2.valid?, @d2.errors.full_messages.inspect
# save attachment instance to run after hooks (including tempfile cleanup)
@d2.avatar.save
@d2.avatar = @bad_file
assert ! @d2.valid?
# save attachment instance to run after hooks (including tempfile cleanup)
@d2.avatar.save
end
it "is able to reload without saving and not have the file disappear" do
@dummy.avatar = @file
assert @dummy.save, @dummy.errors.full_messages.inspect
@dummy.avatar.clear
assert_nil @dummy.avatar_file_name
@dummy.reload
assert_equal "5k.png", @dummy.avatar_file_name
end
context "that is assigned its file from another Paperclip attachment" do
before do
@dummy2 = Dummy.new
@file2 = File.new(fixture_file("12k.png"), 'rb')
assert @dummy2.avatar = @file2
@dummy2.save
end
after { @file2.close }
it "works when assigned a file" do
assert_not_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`,
`identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"`
assert @dummy.avatar = @dummy2.avatar
@dummy.save
assert_equal @dummy.avatar_file_name, @dummy2.avatar_file_name
assert_equal `identify -format "%wx%h" "#{@dummy.avatar.path(:original)}"`,
`identify -format "%wx%h" "#{@dummy2.avatar.path(:original)}"`
end
end
end
context "A model with an attachments association and a Paperclip attachment" do
before do
Dummy.class_eval do
has_many :attachments, class_name: 'Dummy'
end
@file = File.new(fixture_file("5k.png"), 'rb')
@dummy = Dummy.new
@dummy.avatar = @file
end
after { @file.close }
it "does not error when saving" do
@dummy.save!
end
end
context "A model with an attachment with hash in file name" do
before do
@settings = { styles: { thumb: "50x50#" },
path: ":rails_root/public/system/:attachment/:id_partition/:style/:hash.:extension",
url: "/system/:attachment/:id_partition/:style/:hash.:extension",
hash_secret: "somesecret" }
rebuild_model @settings
@file = File.new(fixture_file("5k.png"), 'rb')
@dummy = Dummy.create! avatar: @file
end
after do
@file.close
end
it "is accessible" do
assert_file_exists(@dummy.avatar.path(:original))
assert_file_exists(@dummy.avatar.path(:thumb))
end
context "when new style is added" do
before do
@dummy.avatar.options[:styles][:mini] = "25x25#"
@dummy.avatar.instance_variable_set :@normalized_styles, nil
Time.stubs(now: Time.now + 10)
@dummy.avatar.reprocess!
@dummy.reload
end
it "makes all the styles accessible" do
assert_file_exists(@dummy.avatar.path(:original))
assert_file_exists(@dummy.avatar.path(:thumb))
assert_file_exists(@dummy.avatar.path(:mini))
end
end
end
if ENV['S3_BUCKET']
def s3_files_for attachment
[:thumb, :medium, :large, :original].inject({}) do |files, style|
data = `curl "#{attachment.url(style)}" 2>/dev/null`.chomp
t = Tempfile.new("paperclip-test")
t.binmode
t.write(data)
t.rewind
files[style] = t
files
end
end
def s3_headers_for attachment, style
`curl --head "#{attachment.url(style)}" 2>/dev/null`.split("
").inject({}) do |h,head|
split_head = head.chomp.split(/\s*:\s*/, 2)
h[split_head.first.downcase] = split_head.last unless split_head.empty?
h
end
end
context "A model with an S3 attachment" do
before do
rebuild_model(
styles: {
large: "300x300>",
medium: "100x100",
thumb: ["32x32#", :gif],
custom: {
geometry: "32x32#",
s3_headers: { 'Cache-Control' => 'max-age=31557600' },
s3_metadata: { 'foo' => 'bar'}
}
},
storage: :s3,
s3_credentials: File.new(fixture_file('s3.yml')),
s3_options: { logger: Paperclip.logger },
default_style: :medium,
bucket: ENV['S3_BUCKET'],
path: ":class/:attachment/:id/:style/:basename.:extension"
)
@dummy = Dummy.new
@file = File.new(fixture_file('5k.png'), 'rb')
@bad_file = File.new(fixture_file('bad.png'), 'rb')
@dummy.avatar = @file
@dummy.valid?
@dummy.save!
@files_on_s3 = s3_files_for(@dummy.avatar)
end
after do
@file.close
@bad_file.close
@files_on_s3.values.each(&:close) if @files_on_s3
end
context 'assigning itself to a new model' do
before do
@d2 = Dummy.new
@d2.avatar = @dummy.avatar
@d2.save
end
it "has the same name as the old file" do
assert_equal @d2.avatar.original_filename, @dummy.avatar.original_filename
end
end
it "has the same contents as the original" do
assert_equal @file.read, @files_on_s3[:original].read
end
it "writes and delete its files" do
[["434x66", :original],
["300x46", :large],
["100x15", :medium],
["32x32", :thumb]].each do |geo, style|
cmd = %Q[identify -format "%wx%h" "#{@files_on_s3[style].path}"]
assert_equal geo, `#{cmd}`.chomp, cmd
end
@d2 = Dummy.find(@dummy.id)
@d2_files = s3_files_for @d2.avatar
[["434x66", :original],
["300x46", :large],
["100x15", :medium],
["32x32", :thumb]].each do |geo, style|
cmd = %Q[identify -format "%wx%h" "#{@d2_files[style].path}"]
assert_equal geo, `#{cmd}`.chomp, cmd
end
@dummy.avatar.clear
assert_nil @dummy.avatar_file_name
assert @dummy.valid?
assert @dummy.save
[:thumb, :medium, :large, :original].each do |style|
assert ! @dummy.avatar.exists?(style)
end
@d2 = Dummy.find(@dummy.id)
assert_nil @d2.avatar_file_name
end
it "works exactly the same when new as when reloaded" do
@d2 = Dummy.find(@dummy.id)
assert_equal @dummy.avatar_file_name, @d2.avatar_file_name
[:thumb, :medium, :large, :original].each do |style|
begin
first_file = open(@dummy.avatar.url(style))
second_file = open(@dummy.avatar.url(style))
assert_equal first_file.read, second_file.read
ensure
first_file.close if first_file
second_file.close if second_file
end
end
@d2.avatar.clear
assert @d2.save
[:thumb, :medium, :large, :original].each do |style|
assert ! @dummy.avatar.exists?(style)
end
end
it "knows the difference between good files, bad files, and nil" do
@dummy.avatar = @bad_file
assert ! @dummy.valid?
@dummy.avatar = nil
assert @dummy.valid?
Dummy.validates_attachment_presence :avatar
@d2 = Dummy.find(@dummy.id)
@d2.avatar = @file
assert @d2.valid?
@d2.avatar = @bad_file
assert ! @d2.valid?
@d2.avatar = nil
assert ! @d2.valid?
end
it "is able to reload without saving and not have the file disappear" do
@dummy.avatar = @file
assert @dummy.save
@dummy.avatar = nil
assert_nil @dummy.avatar_file_name
@dummy.reload
assert_equal "5k.png", @dummy.avatar_file_name
end
it "has the right content type" do
headers = s3_headers_for(@dummy.avatar, :original)
assert_equal 'image/png', headers['content-type']
end
it "has the right style-specific headers" do
headers = s3_headers_for(@dummy.avatar, :custom)
assert_equal 'max-age=31557600', headers['cache-control']
end
it "has the right style-specific metadata" do
headers = s3_headers_for(@dummy.avatar, :custom)
assert_equal 'bar', headers['x-amz-meta-foo']
end
context "with non-english character in the file name" do
before do
@file.stubs(:original_filename).returns("クリップ.png")
@dummy.avatar = @file
end
it "does not raise any error" do
@dummy.save!
end
end
end
end
context "Copying attachments between models" do
before do
rebuild_model
@file = File.new(fixture_file("5k.png"), 'rb')
end
after { @file.close }
it "succeeds when original attachment is a file" do
original = Dummy.new
original.avatar = @file
assert original.save
copy = Dummy.new
copy.avatar = original.avatar
assert copy.save
assert copy.avatar.present?
end
it "succeeds when original attachment is empty" do
original = Dummy.create!
copy = Dummy.new
copy.avatar = @file
assert copy.save
assert copy.avatar.present?
copy.avatar = original.avatar
assert copy.save
assert !copy.avatar.present?
end
end
end