Reviewed-on: #413 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #413 でマージされました.
このコミットが含まれているのは:
@@ -1,4 +1,5 @@
|
||||
require 'rails_helper'
|
||||
require 'tempfile'
|
||||
|
||||
RSpec.describe Post, type: :model do
|
||||
before do
|
||||
@@ -65,4 +66,296 @@ RSpec.describe Post, type: :model do
|
||||
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'thumbnail processing' do
|
||||
def image_blob(width:, height:, background:, draw: nil)
|
||||
Tempfile.create(['post-thumbnail', '.png']) do |file|
|
||||
MiniMagick::Tool::Convert.new do |convert|
|
||||
convert.size "#{ width }x#{ height }"
|
||||
convert.xc background
|
||||
draw&.call(convert)
|
||||
convert << file.path
|
||||
end
|
||||
File.binread(file.path)
|
||||
end
|
||||
end
|
||||
|
||||
def upload_for(blob)
|
||||
StringIO.new(blob).tap(&:rewind)
|
||||
end
|
||||
|
||||
def read_image(attachment)
|
||||
blob =
|
||||
attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
|
||||
MiniMagick::Image.read(blob)
|
||||
end
|
||||
|
||||
def colour_at(image, x, y)
|
||||
image.get_pixels.fetch(y).fetch(x)
|
||||
end
|
||||
|
||||
def expect_green(pixel)
|
||||
expect(pixel[1]).to be > pixel[0] + 40
|
||||
expect(pixel[1]).to be > pixel[2] + 40
|
||||
end
|
||||
|
||||
def expect_red(pixel)
|
||||
expect(pixel[0]).to be > pixel[1] + 40
|
||||
expect(pixel[0]).to be > pixel[2] + 40
|
||||
end
|
||||
|
||||
def expect_blue(pixel)
|
||||
expect(pixel[2]).to be > pixel[0] + 40
|
||||
expect(pixel[2]).to be > pixel[1] + 40
|
||||
end
|
||||
|
||||
describe '.resized_thumbnail_attachment' do
|
||||
it 'fits a wide image within 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 360,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 90,0 269,179'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 90])
|
||||
expect_red(colour_at(image, 0, 45))
|
||||
expect_green(colour_at(image, 90, 45))
|
||||
expect_red(colour_at(image, 179, 45))
|
||||
end
|
||||
|
||||
it 'fits a tall image within 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 360,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 0,90 179,269'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([90, 180])
|
||||
expect_red(colour_at(image, 45, 0))
|
||||
expect_green(colour_at(image, 45, 90))
|
||||
expect_red(colour_at(image, 45, 179))
|
||||
end
|
||||
|
||||
it 'keeps a square image square without distortion' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'blue'
|
||||
convert.draw 'rectangle 90,0 179,179'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_red(colour_at(image, 20, 90))
|
||||
expect_blue(colour_at(image, 160, 90))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attach_thumbnail_from_url!' do
|
||||
it 'attaches a fetched remote image through the common resize path' do
|
||||
blob = image_blob(
|
||||
width: 240,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 30,0 209,179'
|
||||
})
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
blob,
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
expect(post.thumbnail).to receive(:attach).once.and_call_original
|
||||
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
|
||||
expect(post.thumbnail).to be_attached
|
||||
image = read_image(post.thumbnail)
|
||||
expect(image.dimensions).to eq([180, 135])
|
||||
end
|
||||
|
||||
it 'does not attach anything when thumbnail conversion fails' do
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
'not-an-image',
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
allow(described_class).to receive(:resized_thumbnail_attachment)
|
||||
.and_raise(MiniMagick::Error, 'convert failed')
|
||||
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
expect {
|
||||
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
|
||||
|
||||
expect(post.thumbnail).not_to be_attached
|
||||
end
|
||||
|
||||
it 'keeps an existing thumbnail when remote conversion fails' do
|
||||
existing = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
existing.thumbnail.attach(
|
||||
io: StringIO.new('existing'),
|
||||
filename: 'existing.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
blob_id = existing.thumbnail.blob.id
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
'not-an-image',
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
allow(described_class).to receive(:resized_thumbnail_attachment)
|
||||
.and_raise(MiniMagick::Error, 'convert failed')
|
||||
|
||||
expect {
|
||||
existing.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
|
||||
|
||||
existing.reload
|
||||
expect(existing.thumbnail).to be_attached
|
||||
expect(existing.thumbnail.blob.id).to eq(blob_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'original created datetime validation' do
|
||||
it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.title = 'updated title'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'rejects second-bearing updates when the datetime field changes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34:30Z'
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts fixing persisted datetimes to minute precision' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34Z'
|
||||
post.original_created_before = '2024-01-01T12:35Z'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for second precision values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:30',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for fractional-second values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:00.123',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'checks range rules only for valid minute-precision endpoints' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:34'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_at]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts a one-minute range at minute precision' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects invalid calendar dates and invalid hours' do
|
||||
invalid_dates = [
|
||||
'2024-02-31T12:00',
|
||||
'2023-02-29T12:00',
|
||||
'2024-02-29T24:00'
|
||||
]
|
||||
|
||||
invalid_dates.each do |value|
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: value
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
新しい課題から参照
ユーザをブロックする