303 行
9.7 KiB
Ruby
303 行
9.7 KiB
Ruby
require 'rails_helper'
|
|
require 'tempfile'
|
|
|
|
RSpec.describe Post, type: :model do
|
|
before do
|
|
PostUrlSanitisationRule.unscoped.delete_all
|
|
end
|
|
|
|
describe 'URL normalisation' do
|
|
it 'normalises the HTTP URL before applying sanitisation rules' do
|
|
PostUrlSanitisationRule.create!(
|
|
priority: 10,
|
|
source_pattern: '\\Ahttps://example\\.com/videos/([^/]+)\\z',
|
|
replacement: 'https://example.com/watch/\\1'
|
|
)
|
|
|
|
post = described_class.create!(
|
|
title: 'normalised URL',
|
|
url: ' https://EXAMPLE.com/videos/123/ '
|
|
)
|
|
|
|
expect(post.url).to eq('https://example.com/watch/123')
|
|
end
|
|
|
|
it 'does not normalise an unchanged URL when another attribute changes' do
|
|
post = create(:post)
|
|
post.update_column(:url, 'https://EXAMPLE.com/unchanged/')
|
|
|
|
post.update!(title: 'updated title')
|
|
|
|
expect(post.reload.url).to eq('https://EXAMPLE.com/unchanged/')
|
|
end
|
|
|
|
it 'validates the sanitised URL length' do
|
|
path = 'a' * 375
|
|
|
|
PostUrlSanitisationRule.create!(
|
|
priority: 10,
|
|
source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
|
|
replacement: 'https://example.com/\\1\\1'
|
|
)
|
|
|
|
post = described_class.new(
|
|
title: 'long URL',
|
|
url: "https://example.com/#{ path }"
|
|
)
|
|
|
|
expect(post).to be_invalid
|
|
expect(post.errors.details.fetch(:url)).to include(
|
|
error: :too_long,
|
|
count: 768
|
|
)
|
|
end
|
|
|
|
it 'validates uniqueness after sanitisation' do
|
|
PostUrlSanitisationRule.create!(
|
|
priority: 10,
|
|
source_pattern: '\\Ahttps://example\\.com/alias\\z',
|
|
replacement: 'https://example.com/canonical'
|
|
)
|
|
create(:post, url: 'https://example.com/canonical')
|
|
|
|
post = described_class.new(title: 'duplicate URL', url: 'https://example.com/alias')
|
|
|
|
expect(post).to be_invalid
|
|
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 'centre-crops a wide image to 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, 180])
|
|
expect_green(colour_at(image, 0, 90))
|
|
expect_green(colour_at(image, 90, 90))
|
|
expect_green(colour_at(image, 179, 90))
|
|
end
|
|
|
|
it 'centre-crops a tall image to 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([180, 180])
|
|
expect_green(colour_at(image, 90, 0))
|
|
expect_green(colour_at(image, 90, 90))
|
|
expect_green(colour_at(image, 90, 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, 180])
|
|
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 '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
|
|
end
|
|
end
|